diff --git a/src/App.tsx b/src/App.tsx index 046bc961..5cd7c3ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -277,6 +277,18 @@ function App() { const zoom = useZoom() const buildNumber = useBuildNumber() + const { status: updateStatus, actions: updateActions } = useUpdater() + + const handleCheckForUpdates = useCallback(async () => { + const result = await updateActions.checkForUpdates() + if (result === 'up-to-date') { + setToastMessage("You're on the latest version") + } else if (result === 'error') { + setToastMessage('Could not check for updates') + } + // 'available' → UpdateBanner handles it automatically + }, [updateActions, setToastMessage]) + const commands = useAppCommands({ activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs, @@ -314,10 +326,10 @@ function App() { }, onOpenVault: vaultSwitcher.handleOpenLocalFolder, onToggleAIChat: dialogs.toggleAIChat, + onCheckForUpdates: handleCheckForUpdates, + isUpdating: updateStatus.state === 'downloading' || updateStatus.state === 'ready', }) - const { status: updateStatus, actions: updateActions } = useUpdater() - const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null // Show welcome/onboarding screen when vault doesn't exist diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 3ecbb77d..d4c364b7 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -54,6 +54,8 @@ interface AppCommandsConfig { onOpenTheme?: (themeId: string) => void onOpenVault?: () => void onToggleAIChat?: () => void + onCheckForUpdates?: () => void + isUpdating?: boolean } /** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */ @@ -151,6 +153,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onOpenTheme: config.onOpenTheme, onOpenVault: config.onOpenVault, onToggleAIChat: config.onToggleAIChat, + onCheckForUpdates: config.onCheckForUpdates, + isUpdating: config.isUpdating, }) useKeyboardNavigation({ diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index 78493c16..7c817fb3 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -281,6 +281,41 @@ describe('useCommandRegistry', () => { expect(onOpenDailyNote).toHaveBeenCalled() }) + describe('check-updates command', () => { + it('has check-updates command in Settings group', () => { + const { result } = renderHook(() => useCommandRegistry(makeConfig())) + const cmd = result.current.find(c => c.id === 'check-updates') + expect(cmd).toBeDefined() + expect(cmd!.label).toBe('Check for Updates') + expect(cmd!.group).toBe('Settings') + expect(cmd!.keywords).toContain('update') + expect(cmd!.keywords).toContain('version') + }) + + it('is enabled when not updating', () => { + const { result } = renderHook(() => + useCommandRegistry(makeConfig({ isUpdating: false })), + ) + expect(result.current.find(c => c.id === 'check-updates')!.enabled).toBe(true) + }) + + it('is disabled when updating', () => { + const { result } = renderHook(() => + useCommandRegistry(makeConfig({ isUpdating: true })), + ) + expect(result.current.find(c => c.id === 'check-updates')!.enabled).toBe(false) + }) + + it('calls onCheckForUpdates when executed', () => { + const onCheckForUpdates = vi.fn() + const { result } = renderHook(() => + useCommandRegistry(makeConfig({ onCheckForUpdates })), + ) + result.current.find(c => c.id === 'check-updates')!.execute() + expect(onCheckForUpdates).toHaveBeenCalled() + }) + }) + describe('type-aware commands', () => { it('generates "New [Type]" commands from vault entries', () => { const entries = [ diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 60f26c3e..ee9453ee 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -34,6 +34,8 @@ interface CommandRegistryConfig { onToggleInspector: () => void onToggleRawEditor?: () => void onToggleAIChat?: () => void + onCheckForUpdates?: () => void + isUpdating?: boolean onZoomIn: () => void onZoomOut: () => void onZoomReset: () => void @@ -175,6 +177,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction onSelect, onOpenDailyNote, onCloseTab, onGoBack, onGoForward, canGoBack, canGoForward, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, + onCheckForUpdates, isUpdating, } = config const hasActiveNote = activeTabPath !== null @@ -229,6 +232,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction // Settings { id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings }, { id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() }, + { id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: !isUpdating, execute: () => onCheckForUpdates?.() }, // Type-aware: "New [Type]" and "List [Type]" ...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect), @@ -240,6 +244,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote, onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault, + onCheckForUpdates, isUpdating, onZoomIn, onZoomOut, onZoomReset, zoomLevel, onSelect, onOpenDailyNote, onCloseTab, onGoBack, onGoForward, canGoBack, canGoForward, diff --git a/src/hooks/useUpdater.test.ts b/src/hooks/useUpdater.test.ts index de1b8c5a..d50f599c 100644 --- a/src/hooks/useUpdater.test.ts +++ b/src/hooks/useUpdater.test.ts @@ -199,4 +199,73 @@ describe('useUpdater', () => { expect(result.current.status).toEqual({ state: 'ready', version: '1.2.0' }) expect(mockDownload).toHaveBeenCalled() }) + + describe('checkForUpdates (manual)', () => { + it('returns up-to-date when no update is available', async () => { + vi.mocked(isTauri).mockReturnValue(true) + mockCheck.mockResolvedValue(null) + + const { result } = renderHook(() => useUpdater()) + + let checkResult: string | undefined + await act(async () => { + checkResult = await result.current.actions.checkForUpdates() + }) + + expect(checkResult).toBe('up-to-date') + expect(result.current.status).toEqual({ state: 'idle' }) + }) + + it('returns available and sets status when update exists', async () => { + vi.mocked(isTauri).mockReturnValue(true) + mockCheck.mockResolvedValue({ + version: '3.0.0', + body: 'Major release', + downloadAndInstall: vi.fn(), + }) + + const { result } = renderHook(() => useUpdater()) + + let checkResult: string | undefined + await act(async () => { + checkResult = await result.current.actions.checkForUpdates() + }) + + expect(checkResult).toBe('available') + expect(result.current.status).toEqual({ + state: 'available', + version: '3.0.0', + notes: 'Major release', + }) + }) + + it('returns error on network failure', async () => { + vi.mocked(isTauri).mockReturnValue(true) + mockCheck.mockRejectedValue(new Error('No internet')) + + const { result } = renderHook(() => useUpdater()) + + let checkResult: string | undefined + await act(async () => { + checkResult = await result.current.actions.checkForUpdates() + }) + + expect(checkResult).toBe('error') + expect(console.warn).toHaveBeenCalledWith('[updater] Failed to check for updates') + }) + + it('returns up-to-date when not in Tauri', async () => { + vi.mocked(isTauri).mockReturnValue(false) + + const { result } = renderHook(() => useUpdater()) + + let checkResult: string | undefined + await act(async () => { + checkResult = await result.current.actions.checkForUpdates() + }) + + expect(checkResult).toBe('up-to-date') + expect(mockCheck).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/hooks/useUpdater.ts b/src/hooks/useUpdater.ts index 2a1575fa..d8632063 100644 --- a/src/hooks/useUpdater.ts +++ b/src/hooks/useUpdater.ts @@ -11,7 +11,10 @@ export type UpdateStatus = | { state: 'ready'; version: string } | { state: 'error' } +export type UpdateCheckResult = 'up-to-date' | 'available' | 'error' + export interface UpdateActions { + checkForUpdates: () => Promise startDownload: () => void openReleaseNotes: () => void dismiss: () => void @@ -21,31 +24,32 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } { const [status, setStatus] = useState({ state: 'idle' }) const updateRef = useRef(null) + const checkForUpdates = useCallback(async (): Promise => { + if (!isTauri()) return 'up-to-date' + + try { + const { check } = await import('@tauri-apps/plugin-updater') + const update = await check() + if (!update) return 'up-to-date' + + updateRef.current = update + setStatus({ + state: 'available', + version: update.version, + notes: update.body ?? undefined, + }) + return 'available' + } catch { + console.warn('[updater] Failed to check for updates') + return 'error' + } + }, []) + useEffect(() => { if (!isTauri()) return - - const checkForUpdates = async () => { - try { - const { check } = await import('@tauri-apps/plugin-updater') - const update = await check() - if (!update) return // up to date - - updateRef.current = update - setStatus({ - state: 'available', - version: update.version, - notes: update.body ?? undefined, - }) - } catch { - // Network error or 404 — fail silently - console.warn('[updater] Failed to check for updates') - } - } - - // Delay so the app can render first - const timer = setTimeout(checkForUpdates, 3000) + const timer = setTimeout(() => { checkForUpdates() }, 3000) return () => clearTimeout(timer) - }, []) + }, [checkForUpdates]) const startDownload = useCallback(async () => { const update = updateRef.current as { @@ -88,7 +92,7 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } { setStatus({ state: 'idle' }) }, []) - return { status, actions: { startDownload, openReleaseNotes, dismiss } } + return { status, actions: { checkForUpdates, startDownload, openReleaseNotes, dismiss } } } /**