diff --git a/src/App.tsx b/src/App.tsx index cc446ae3..80d7f926 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,6 +39,7 @@ import { useZoom } from './hooks/useZoom' import { useVaultConfig } from './hooks/useVaultConfig' import { useBuildNumber } from './hooks/useBuildNumber' import { useOnboarding } from './hooks/useOnboarding' +import { useNetworkStatus } from './hooks/useNetworkStatus' import { useAppNavigation } from './hooks/useAppNavigation' import { useAiActivity } from './hooks/useAiActivity' import { useBulkActions } from './hooks/useBulkActions' @@ -102,6 +103,7 @@ function App() { const [showFeedback, setShowFeedback] = useState(false) const openFeedback = useCallback(() => setShowFeedback(true), []) const closeFeedback = useCallback(() => setShowFeedback(false), []) + const networkStatus = useNetworkStatus() useEffect(() => { const handleOpenAiChat = () => { @@ -621,7 +623,7 @@ function App() { // Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known) if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing')) { - return + return } // Show loading spinner while checking vault (skip for note windows) @@ -737,7 +739,7 @@ function App() { - handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} /> + handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isOffline={networkStatus.isOffline} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} /> setToastMessage(null)} /> /** Welcome screen view - extracted from main App component */ -function WelcomeView({ onboarding }: { onboarding: OnboardingState }) { +function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; isOffline: boolean }) { const state = onboarding.state as { status: 'welcome' | 'vault-missing'; defaultPath: string; vaultPath?: string } return (
@@ -794,6 +796,7 @@ function WelcomeView({ onboarding }: { onboarding: OnboardingState }) { onRetryCreateVault={onboarding.retryCreateVault} onCreateNewVault={onboarding.handleCreateNewVault} onOpenFolder={onboarding.handleOpenFolder} + isOffline={isOffline} creatingAction={onboarding.creatingAction} error={onboarding.error} canRetryTemplate={onboarding.canRetryTemplate} diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index 8ffe0fb2..02aad2f0 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -195,6 +195,38 @@ describe('StatusBar', () => { expect(screen.getByText('Clone Git repo')).toBeInTheDocument() }) + it('shows the Getting Started clone action in the vault menu when provided', () => { + render( + + ) + + fireEvent.click(screen.getByTitle('Switch vault')) + expect(screen.getByText('Clone Getting Started Vault')).toBeInTheDocument() + }) + + it('calls onCloneGettingStarted when clicking the vault menu action', () => { + const onCloneGettingStarted = vi.fn() + render( + + ) + + fireEvent.click(screen.getByTitle('Switch vault')) + fireEvent.click(screen.getByText('Clone Getting Started Vault')) + expect(onCloneGettingStarted).toHaveBeenCalledOnce() + }) + it('shows Changes badge with count when modifiedCount is > 0', () => { render() expect(screen.getByTestId('status-modified-count')).toBeInTheDocument() @@ -300,6 +332,13 @@ describe('StatusBar', () => { expect(screen.getByText('Pull required')).toBeInTheDocument() }) + it('shows an offline chip when offline', () => { + render( + + ) + expect(screen.getByTestId('status-offline')).toHaveTextContent('Offline') + }) + it('calls onPullAndPush when clicking Pull required badge', () => { const onPullAndPush = vi.fn() render( diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index adf1f539..56cb4eac 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -19,9 +19,11 @@ interface StatusBarProps { onOpenSettings?: () => void onOpenLocalFolder?: () => void onCloneVault?: () => void + onCloneGettingStarted?: () => void onClickPending?: () => void onClickPulse?: () => void onCommitPush?: () => void + isOffline?: boolean isGitVault?: boolean syncStatus?: SyncStatus lastSyncTime?: number | null @@ -52,9 +54,11 @@ export function StatusBar({ onOpenSettings, onOpenLocalFolder, onCloneVault, + onCloneGettingStarted, onClickPending, onClickPulse, onCommitPush, + isOffline = false, isGitVault = false, syncStatus = 'idle', lastSyncTime = null, @@ -106,9 +110,11 @@ export function StatusBar({ onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onCloneVault={onCloneVault} + onCloneGettingStarted={onCloneGettingStarted} onClickPending={onClickPending} onClickPulse={onClickPulse} onCommitPush={onCommitPush} + isOffline={isOffline} isGitVault={isGitVault} syncStatus={syncStatus} lastSyncTime={lastSyncTime} diff --git a/src/components/WelcomeScreen.test.tsx b/src/components/WelcomeScreen.test.tsx index 5a4c239e..f55ce815 100644 --- a/src/components/WelcomeScreen.test.tsx +++ b/src/components/WelcomeScreen.test.tsx @@ -9,6 +9,7 @@ const defaultProps = { onRetryCreateVault: vi.fn(), onCreateNewVault: vi.fn(), onOpenFolder: vi.fn(), + isOffline: false, creatingAction: null as 'template' | 'empty' | null, error: null, canRetryTemplate: false, @@ -34,6 +35,12 @@ describe('WelcomeScreen', () => { expect(screen.getByText(/~\/Documents\/Laputa/)).toBeInTheDocument() }) + it('shows offline guidance and disables the template option when offline', () => { + render() + expect(screen.getByTestId('welcome-create-vault')).toBeDisabled() + expect(screen.getByText(/Requires internet — clone later/)).toBeInTheDocument() + }) + it('calls onCreateNewVault when create new button is clicked', () => { const onCreateNewVault = vi.fn() render() diff --git a/src/components/WelcomeScreen.tsx b/src/components/WelcomeScreen.tsx index c8a41ae9..6cace548 100644 --- a/src/components/WelcomeScreen.tsx +++ b/src/components/WelcomeScreen.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import type { ReactNode } from 'react' import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react' interface WelcomeScreenProps { @@ -9,11 +10,21 @@ interface WelcomeScreenProps { onRetryCreateVault: () => void onCreateNewVault: () => void onOpenFolder: () => void + isOffline: boolean creatingAction: 'template' | 'empty' | null error: string | null canRetryTemplate: boolean } +interface WelcomeScreenPresentation { + heroBackground: string + heroIcon: ReactNode + openFolderLabel: string + subtitle: string + templateDescription: string + title: string +} + const CONTAINER_STYLE: React.CSSProperties = { width: '100%', height: '100%', @@ -189,6 +200,36 @@ function OptionButton({ ) } +function getWelcomeScreenPresentation( + mode: WelcomeScreenProps['mode'], + defaultVaultPath: string, + isOffline: boolean, +): WelcomeScreenPresentation { + if (mode === 'welcome') { + return { + heroBackground: 'var(--accent-blue-light, #EBF4FF)', + heroIcon: , + openFolderLabel: 'Open existing vault', + subtitle: 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.', + templateDescription: isOffline + ? `Requires internet — clone later. Suggested path: ${defaultVaultPath}` + : `Download the starter vault template — suggested path: ${defaultVaultPath}`, + title: 'Welcome to Tolaria', + } + } + + return { + heroBackground: 'var(--accent-yellow-light, #FFF3E0)', + heroIcon: , + openFolderLabel: 'Choose a different folder', + subtitle: 'The vault folder could not be found on disk.\nIt may have been moved or deleted.', + templateDescription: isOffline + ? `Requires internet — clone later. Suggested path: ${defaultVaultPath}` + : `Download the starter vault template — suggested path: ${defaultVaultPath}`, + title: 'Vault not found', + } +} + export function WelcomeScreen({ mode, defaultVaultPath, @@ -196,12 +237,13 @@ export function WelcomeScreen({ onRetryCreateVault, onCreateNewVault, onOpenFolder, + isOffline, creatingAction, error, canRetryTemplate, }: WelcomeScreenProps) { - const isWelcome = mode === 'welcome' const busy = creatingAction !== null + const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline) return (
@@ -209,24 +251,16 @@ export function WelcomeScreen({
- {isWelcome - ? - : - } + {presentation.heroIcon}
-

- {isWelcome ? 'Welcome to Tolaria' : 'Vault not found'} -

+

{presentation.title}

- {isWelcome - ? 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.' - : 'The vault folder could not be found on disk.\nIt may have been moved or deleted.' - } + {presentation.subtitle}

@@ -249,7 +283,7 @@ export function WelcomeScreen({ } iconBg="var(--accent-green-light, #E8F5E9)" - label={isWelcome ? 'Open existing vault' : 'Choose a different folder'} + label={presentation.openFolderLabel} description="Point to a folder you already have" onClick={onOpenFolder} disabled={busy} @@ -260,11 +294,11 @@ export function WelcomeScreen({ icon={} iconBg="var(--accent-purple-light, #F3E8FF)" label="Get started with a template" - description={`Download the starter vault template \u2014 suggested path: ${defaultVaultPath}`} + description={presentation.templateDescription} loadingLabel="Downloading template…" loadingDescription="Cloning the Getting Started vault template" onClick={onCreateVault} - disabled={busy} + disabled={busy || isOffline} loading={creatingAction === 'template'} testId="welcome-create-vault" /> diff --git a/src/components/status-bar/StatusBarBadges.tsx b/src/components/status-bar/StatusBarBadges.tsx index 1cf3cc1f..e91c8393 100644 --- a/src/components/status-bar/StatusBarBadges.tsx +++ b/src/components/status-bar/StatusBarBadges.tsx @@ -304,6 +304,33 @@ export function CommitBadge({ info }: { info: LastCommitInfo }) { ) } +export function OfflineBadge({ isOffline }: { isOffline?: boolean }) { + if (!isOffline) return null + + return ( + <> + | + + + Offline + + + ) +} + export function SyncBadge({ status, lastSyncTime, diff --git a/src/components/status-bar/StatusBarSections.tsx b/src/components/status-bar/StatusBarSections.tsx index 053bf7b7..0f6e0025 100644 --- a/src/components/status-bar/StatusBarSections.tsx +++ b/src/components/status-bar/StatusBarSections.tsx @@ -11,6 +11,7 @@ import { ConflictBadge, ChangesBadge, McpBadge, + OfflineBadge, PulseBadge, SyncBadge, } from './StatusBarBadges' @@ -25,9 +26,11 @@ interface StatusBarPrimarySectionProps { onSwitchVault: (path: string) => void onOpenLocalFolder?: () => void onCloneVault?: () => void + onCloneGettingStarted?: () => void onClickPending?: () => void onClickPulse?: () => void onCommitPush?: () => void + isOffline?: boolean isGitVault?: boolean syncStatus: SyncStatus lastSyncTime: number | null @@ -61,9 +64,11 @@ export function StatusBarPrimarySection({ onSwitchVault, onOpenLocalFolder, onCloneVault, + onCloneGettingStarted, onClickPending, onClickPulse, onCommitPush, + isOffline = false, isGitVault = false, syncStatus, lastSyncTime, @@ -89,6 +94,7 @@ export function StatusBarPrimarySection({ onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onCloneVault={onCloneVault} + onCloneGettingStarted={onCloneGettingStarted} onRemoveVault={onRemoveVault} /> | @@ -104,6 +110,7 @@ export function StatusBarPrimarySection({ {buildNumber ?? 'b?'} + void onOpenLocalFolder?: () => void onCloneVault?: () => void + onCloneGettingStarted?: () => void onRemoveVault?: (path: string) => void } @@ -38,6 +39,47 @@ interface VaultAction { onClick: () => void } +function buildVaultActions({ + onCloneGettingStarted, + onCloneVault, + onOpenLocalFolder, +}: Pick): VaultAction[] { + const items: VaultAction[] = [] + + if (onOpenLocalFolder) { + items.push({ + key: 'open-local', + icon: , + label: 'Open local folder', + testId: 'vault-menu-open-local', + onClick: onOpenLocalFolder, + }) + } + + if (onCloneVault) { + items.push({ + key: 'clone-git', + icon: , + label: 'Clone Git repo', + testId: 'vault-menu-clone-git', + onClick: onCloneVault, + }) + } + + if (onCloneGettingStarted) { + items.push({ + key: 'clone-getting-started', + icon: , + label: 'Clone Getting Started Vault', + testId: 'vault-menu-clone-getting-started', + accent: true, + onClick: onCloneGettingStarted, + }) + } + + return items +} + function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailable: boolean }) { if (isActive) return if (unavailable) return @@ -142,6 +184,7 @@ export function VaultMenu({ onSwitchVault, onOpenLocalFolder, onCloneVault, + onCloneGettingStarted, onRemoveVault, }: VaultMenuProps) { const [open, setOpen] = useState(false) @@ -152,30 +195,12 @@ export function VaultMenu({ useDismissibleLayer(open, menuRef, () => setOpen(false)) const actions = useMemo(() => { - const items: VaultAction[] = [] - - if (onOpenLocalFolder) { - items.push({ - key: 'open-local', - icon: , - label: 'Open local folder', - testId: 'vault-menu-open-local', - onClick: onOpenLocalFolder, - }) - } - - if (onCloneVault) { - items.push({ - key: 'clone-git', - icon: , - label: 'Clone Git repo', - testId: 'vault-menu-clone-git', - onClick: onCloneVault, - }) - } - - return items - }, [onCloneVault, onOpenLocalFolder]) + return buildVaultActions({ + onCloneGettingStarted, + onCloneVault, + onOpenLocalFolder, + }) + }, [onCloneGettingStarted, onCloneVault, onOpenLocalFolder]) return (
diff --git a/src/hooks/useNetworkStatus.test.ts b/src/hooks/useNetworkStatus.test.ts new file mode 100644 index 00000000..7fa25919 --- /dev/null +++ b/src/hooks/useNetworkStatus.test.ts @@ -0,0 +1,43 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { useNetworkStatus } from './useNetworkStatus' + +describe('useNetworkStatus', () => { + let online = true + + beforeEach(() => { + online = true + Object.defineProperty(window.navigator, 'onLine', { + configurable: true, + get: () => online, + }) + }) + + it('uses navigator.onLine for the initial state', () => { + online = false + + const { result } = renderHook(() => useNetworkStatus()) + + expect(result.current.isOffline).toBe(true) + }) + + it('updates when online and offline events fire', () => { + const { result } = renderHook(() => useNetworkStatus()) + + expect(result.current.isOffline).toBe(false) + + act(() => { + online = false + window.dispatchEvent(new Event('offline')) + }) + + expect(result.current.isOffline).toBe(true) + + act(() => { + online = true + window.dispatchEvent(new Event('online')) + }) + + expect(result.current.isOffline).toBe(false) + }) +}) diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts new file mode 100644 index 00000000..9a6707bd --- /dev/null +++ b/src/hooks/useNetworkStatus.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react' + +function detectOffline(): boolean { + if (typeof navigator === 'undefined') { + return false + } + + return navigator.onLine === false +} + +export function useNetworkStatus() { + const [isOffline, setIsOffline] = useState(detectOffline) + + useEffect(() => { + const handleOnline = () => setIsOffline(false) + const handleOffline = () => setIsOffline(true) + + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + + return () => { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + } + }, []) + + return { isOffline } +} diff --git a/src/hooks/useVaultSwitcher.test.ts b/src/hooks/useVaultSwitcher.test.ts index fa73102a..e3f9057a 100644 --- a/src/hooks/useVaultSwitcher.test.ts +++ b/src/hooks/useVaultSwitcher.test.ts @@ -321,7 +321,7 @@ describe('useVaultSwitcher', () => { }) expect(result.current.vaultPath).toBe(DEFAULT_VAULTS[0].path) - expect(onToast).toHaveBeenCalledWith('Getting Started vault restored') + expect(onToast).toHaveBeenCalledWith('Getting Started vault ready') }) it('attempts to create vault on disk if it does not exist', async () => { @@ -351,6 +351,37 @@ describe('useVaultSwitcher', () => { expect(mockInvokeFn).toHaveBeenCalledWith('check_vault_exists', { path: DEFAULT_VAULTS[0].path }) expect(mockInvokeFn).toHaveBeenCalledWith('create_getting_started_vault', { targetPath: DEFAULT_VAULTS[0].path }) }) + + it('shows a friendly toast and keeps the hidden vault hidden when cloning fails', async () => { + mockVaultListStore = { + vaults: [{ label: 'Work', path: '/work/vault' }], + active_vault: '/work/vault', + hidden_defaults: [DEFAULT_VAULTS[0].path], + } + mockInvokeFn.mockImplementation((cmd: string, args?: Record) => { + if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore }) + if (cmd === 'save_vault_list') { + mockVaultListStore = { ...(args as { list: PersistedVaultList }).list } + return Promise.resolve(null) + } + if (cmd === 'check_vault_exists') return Promise.resolve(false) + if (cmd === 'create_getting_started_vault') { + return Promise.reject('git clone failed: fatal: unable to access') + } + return Promise.resolve(null) + }) + + const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast })) + await waitFor(() => { expect(result.current.loaded).toBe(true) }) + + await act(async () => { + await result.current.restoreGettingStarted() + }) + + expect(result.current.vaultPath).toBe('/work/vault') + expect(result.current.isGettingStartedHidden).toBe(true) + expect(onToast).toHaveBeenCalledWith('Getting Started requires internet. Clone it later.') + }) }) describe('default vault path', () => { diff --git a/src/hooks/useVaultSwitcher.ts b/src/hooks/useVaultSwitcher.ts index 8a7408f7..ec6451b0 100644 --- a/src/hooks/useVaultSwitcher.ts +++ b/src/hooks/useVaultSwitcher.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' import { pickFolder } from '../utils/vault-dialog' @@ -25,6 +26,67 @@ interface UseVaultSwitcherOptions { onToast: (msg: string) => void } +interface PersistedVaultState { + defaultPath: string + extraVaults: VaultOption[] + hiddenDefaults: string[] + loaded: boolean + setExtraVaults: Dispatch> + setHiddenDefaults: Dispatch> + setVaultPath: Dispatch> + vaultPath: string +} + +interface VaultCollections { + allVaults: VaultOption[] + defaultVaults: VaultOption[] + isGettingStartedHidden: boolean +} + +interface PersistedVaultStore { + defaultPath: string + extraVaults: VaultOption[] + hiddenDefaults: string[] + loaded: boolean + setDefaultPath: Dispatch> + setExtraVaults: Dispatch> + setHiddenDefaults: Dispatch> + setLoaded: Dispatch> + setVaultPath: Dispatch> + vaultPath: string +} + +interface VaultActionOptions extends PersistedVaultState, VaultCollections { + onSwitchRef: MutableRefObject<() => void> + onToastRef: MutableRefObject<(msg: string) => void> +} + +interface RestoreGettingStartedOptions { + defaultPath: string + onToastRef: MutableRefObject<(msg: string) => void> + setHiddenDefaults: Dispatch> + switchVault: (path: string) => void +} + +interface RemainingVaultOptions { + defaultVaults: VaultOption[] + extraVaults: VaultOption[] + hiddenDefaults: string[] + isDefault: boolean + removedPath: string +} + +interface RemoveVaultStateOptions extends RemainingVaultOptions { + onSwitchRef: MutableRefObject<() => void> + setExtraVaults: Dispatch> + setHiddenDefaults: Dispatch> + setVaultPath: Dispatch> +} + +interface RemoveVaultActionOptions extends RemoveVaultStateOptions { + onToastRef: MutableRefObject<(msg: string) => void> +} + function labelFromPath(path: string): string { return path.split('/').pop() || 'Local Vault' } @@ -33,153 +95,461 @@ function tauriCall(command: string, args: Record): Promise(command, args) : mockInvoke(command, args) } -/** Manages vault path, extra vaults, switching, cloning, and local folder opening. - * Vault list and active vault are persisted via Tauri backend to survive app updates. */ -export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions) { +async function resolveDefaultPath(): Promise { + if (STATIC_DEFAULT_PATH) { + return STATIC_DEFAULT_PATH + } + + try { + return await tauriCall('get_default_vault_path', {}) + } catch { + return '' + } +} + +function syncDefaultVaultExport(path: string) { + DEFAULT_VAULTS[0] = { label: GETTING_STARTED_LABEL, path } +} + +async function loadInitialVaultState() { + const [{ vaults, activeVault, hiddenDefaults }, resolvedDefaultPath] = await Promise.all([ + loadVaultList(), + resolveDefaultPath(), + ]) + + return { activeVault, hiddenDefaults, resolvedDefaultPath, vaults } +} + +function buildDefaultVaults(defaultPath: string): VaultOption[] { + return [{ label: GETTING_STARTED_LABEL, path: defaultPath }] +} + +function buildVisibleDefaultVaults(defaultVaults: VaultOption[], hiddenDefaults: string[]): VaultOption[] { + return defaultVaults.filter(vault => !hiddenDefaults.includes(vault.path)) +} + +function buildAllVaults(visibleDefaults: VaultOption[], extraVaults: VaultOption[]): VaultOption[] { + return [...visibleDefaults, ...extraVaults] +} + +function applyResolvedDefaultPath( + resolvedDefaultPath: string, + setDefaultPath: Dispatch>, +) { + if (!resolvedDefaultPath) { + return + } + + setDefaultPath(resolvedDefaultPath) + syncDefaultVaultExport(resolvedDefaultPath) +} + +function applyInitialVaultTarget( + activeVault: string, + resolvedDefaultPath: string, + setVaultPath: Dispatch>, + onSwitchRef: MutableRefObject<() => void>, +) { + if (activeVault) { + setVaultPath(activeVault) + onSwitchRef.current() + return + } + + if (resolvedDefaultPath) { + setVaultPath(resolvedDefaultPath) + } +} + +function useVaultCollections( + defaultPath: string, + hiddenDefaults: string[], + extraVaults: VaultOption[], +): VaultCollections { + const defaultVaults = useMemo( + () => buildDefaultVaults(defaultPath), + [defaultPath], + ) + const visibleDefaults = useMemo( + () => buildVisibleDefaultVaults(defaultVaults, hiddenDefaults), + [defaultVaults, hiddenDefaults], + ) + const allVaults = useMemo( + () => buildAllVaults(visibleDefaults, extraVaults), + [extraVaults, visibleDefaults], + ) + const isGettingStartedHidden = useMemo( + () => hiddenDefaults.includes(defaultPath), + [defaultPath, hiddenDefaults], + ) + + return { allVaults, defaultVaults, isGettingStartedHidden } +} + +function useLoadPersistedVaultState( + store: PersistedVaultStore, + onSwitchRef: MutableRefObject<() => void>, +) { + const { + setDefaultPath, + setExtraVaults, + setHiddenDefaults, + setLoaded, + setVaultPath, + } = store + + useEffect(() => { + let cancelled = false + + loadInitialVaultState() + .then(({ activeVault, hiddenDefaults: hidden, resolvedDefaultPath, vaults }) => { + if (cancelled) return + + setExtraVaults(vaults) + setHiddenDefaults(hidden) + applyResolvedDefaultPath(resolvedDefaultPath, setDefaultPath) + applyInitialVaultTarget(activeVault, resolvedDefaultPath, setVaultPath, onSwitchRef) + }) + .catch(err => console.warn('Failed to load vault list:', err)) + .finally(() => { + if (!cancelled) { + setLoaded(true) + } + }) + + return () => { cancelled = true } + }, [onSwitchRef, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setVaultPath]) +} + +function usePersistedVaultStorage(store: PersistedVaultStore) { + const { extraVaults, hiddenDefaults, loaded, vaultPath } = store + + useEffect(() => { + if (!loaded) return + + saveVaultList(extraVaults, vaultPath, hiddenDefaults).catch(err => + console.warn('Failed to persist vault list:', err), + ) + }, [extraVaults, hiddenDefaults, loaded, vaultPath]) +} + +function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): PersistedVaultState { const [vaultPath, setVaultPath] = useState(STATIC_DEFAULT_PATH) const [extraVaults, setExtraVaults] = useState([]) const [hiddenDefaults, setHiddenDefaults] = useState([]) const [loaded, setLoaded] = useState(false) const [defaultPath, setDefaultPath] = useState(STATIC_DEFAULT_PATH) - const defaultVaults: VaultOption[] = useMemo( - () => [{ label: GETTING_STARTED_LABEL, path: defaultPath }], - [defaultPath], - ) + const store: PersistedVaultStore = { + defaultPath, + extraVaults, + hiddenDefaults, + loaded, + setDefaultPath, + setExtraVaults, + setHiddenDefaults, + setLoaded, + setVaultPath, + vaultPath, + } - const visibleDefaults = useMemo( - () => defaultVaults.filter(v => !hiddenDefaults.includes(v.path)), - [defaultVaults, hiddenDefaults], - ) - const allVaults = useMemo( - () => [...visibleDefaults, ...extraVaults], - [visibleDefaults, extraVaults], - ) + useLoadPersistedVaultState(store, onSwitchRef) + usePersistedVaultStorage(store) - const isGettingStartedHidden = useMemo( - () => hiddenDefaults.includes(defaultPath), - [hiddenDefaults, defaultPath], - ) + return { + defaultPath, + extraVaults, + hiddenDefaults, + loaded, + setExtraVaults, + setHiddenDefaults, + setVaultPath, + vaultPath, + } +} - const onSwitchRef = useRef(onSwitch) - const onToastRef = useRef(onToast) - useEffect(() => { onSwitchRef.current = onSwitch; onToastRef.current = onToast }) +function formatGettingStartedRestoreError(err: unknown): string { + const message = + typeof err === 'string' + ? err + : err instanceof Error + ? err.message + : `${err}` - const hasLoadedRef = useRef(false) + const networkErrors = [ + 'unable to access', + 'Could not resolve host', + 'network', + 'timed out', + ] - useEffect(() => { - let cancelled = false - loadVaultList() - .then(async ({ vaults, activeVault, hiddenDefaults: hidden }) => { - if (cancelled) return - setExtraVaults(vaults) - setHiddenDefaults(hidden) - if (activeVault) { - setVaultPath(activeVault) - onSwitchRef.current() - } else if (!STATIC_DEFAULT_PATH) { - // Production build: resolve the Getting Started path at runtime - try { - const runtimePath = await tauriCall('get_default_vault_path', {}) - if (!cancelled && runtimePath) { - setDefaultPath(runtimePath) - setVaultPath(runtimePath) - // Keep the module-level export in sync for external consumers - DEFAULT_VAULTS[0] = { label: GETTING_STARTED_LABEL, path: runtimePath } - } - } catch { - // In mock/test mode, command may not exist - } - } - }) - .catch(err => console.warn('Failed to load vault list:', err)) - .finally(() => { - hasLoadedRef.current = true - setLoaded(true) - }) - return () => { cancelled = true } - }, []) + if (networkErrors.some(fragment => message.includes(fragment))) { + return 'Getting Started requires internet. Clone it later.' + } - useEffect(() => { - if (!hasLoadedRef.current) return - saveVaultList(extraVaults, vaultPath, hiddenDefaults).catch(err => - console.warn('Failed to persist vault list:', err), - ) - }, [extraVaults, vaultPath, hiddenDefaults]) + return `Could not prepare Getting Started vault: ${message}` +} - const addVault = useCallback((path: string, label: string) => { - setExtraVaults(prev => { - const exists = prev.some(v => v.path === path) - return exists ? prev : [...prev, { label, path, available: true }] +async function ensureGettingStartedVaultReady(path: string): Promise { + const exists = await tauriCall('check_vault_exists', { path }) + if (!exists) { + await tauriCall('create_getting_started_vault', { targetPath: path }) + } +} + +function addVaultToList( + setExtraVaults: Dispatch>, + path: string, + label: string, +) { + setExtraVaults(previousVaults => { + const exists = previousVaults.some(vault => vault.path === path) + return exists ? previousVaults : [...previousVaults, { label, path, available: true }] + }) +} + +function switchVaultPath( + setVaultPath: Dispatch>, + onSwitchRef: MutableRefObject<() => void>, + path: string, +) { + trackEvent('vault_switched') + setVaultPath(path) + onSwitchRef.current() +} + +function listRemainingVaults({ + defaultVaults, + extraVaults, + hiddenDefaults, + isDefault, + removedPath, +}: RemainingVaultOptions) { + const visibleDefaults = defaultVaults.filter(vault => ( + vault.path !== removedPath + && (!isDefault || !hiddenDefaults.includes(vault.path)) + )) + + return [...visibleDefaults, ...extraVaults.filter(vault => vault.path !== removedPath)] +} + +function removeVaultFromState({ + defaultVaults, + extraVaults, + hiddenDefaults, + isDefault, + onSwitchRef, + removedPath, + setExtraVaults, + setHiddenDefaults, + setVaultPath, +}: RemoveVaultStateOptions) { + if (isDefault) { + setHiddenDefaults(previousHidden => previousHidden.includes(removedPath) ? previousHidden : [...previousHidden, removedPath]) + } else { + setExtraVaults(previousVaults => previousVaults.filter(vault => vault.path !== removedPath)) + } + + setVaultPath(currentPath => { + if (currentPath !== removedPath) { + return currentPath + } + + const remainingVaults = listRemainingVaults({ + defaultVaults, + extraVaults, + hiddenDefaults, + isDefault, + removedPath, }) - }, []) + if (remainingVaults.length === 0) { + return currentPath + } - const switchVault = useCallback((path: string) => { - trackEvent('vault_switched') - setVaultPath(path) onSwitchRef.current() - }, []) + return remainingVaults[0].path + }) +} +function getRemovedVaultLabel( + path: string, + defaultVaults: VaultOption[], + extraVaults: VaultOption[], +): string { + const removedVault = [...defaultVaults, ...extraVaults].find(vault => vault.path === path) + return removedVault?.label ?? labelFromPath(path) +} + +function useSwitchVaultAction( + onSwitchRef: MutableRefObject<() => void>, + setVaultPath: Dispatch>, +) { + return useCallback((path: string) => { + switchVaultPath(setVaultPath, onSwitchRef, path) + }, [onSwitchRef, setVaultPath]) +} + +function useVaultClonedAction( + addAndSwitch: (path: string, label: string) => void, + onToastRef: MutableRefObject<(msg: string) => void>, +) { + return useCallback((path: string, label: string) => { + addAndSwitch(path, label) + onToastRef.current(`Vault "${label}" cloned and opened`) + }, [addAndSwitch, onToastRef]) +} + +function useOpenLocalFolderAction( + addAndSwitch: (path: string, label: string) => void, + onToastRef: MutableRefObject<(msg: string) => void>, +) { + return useCallback(async () => { + const path = await pickFolder('Open vault folder') + if (!path) return + + const label = labelFromPath(path) + addAndSwitch(path, label) + onToastRef.current(`Vault "${label}" opened`) + }, [addAndSwitch, onToastRef]) +} + +function useRemoveVaultAction({ + defaultVaults, + extraVaults, + hiddenDefaults, + onSwitchRef, + onToastRef, + setExtraVaults, + setHiddenDefaults, + setVaultPath, +}: RemoveVaultActionOptions) { + return useCallback((path: string) => { + const isDefault = defaultVaults.some(vault => vault.path === path) + + removeVaultFromState({ + defaultVaults, + extraVaults, + hiddenDefaults, + isDefault, + onSwitchRef, + removedPath: path, + setExtraVaults, + setHiddenDefaults, + setVaultPath, + }) + onToastRef.current(`Vault "${getRemovedVaultLabel(path, defaultVaults, extraVaults)}" removed from list`) + }, [ + defaultVaults, + extraVaults, + hiddenDefaults, + onSwitchRef, + onToastRef, + setExtraVaults, + setHiddenDefaults, + setVaultPath, + ]) +} + +function useRestoreGettingStartedAction(options: RestoreGettingStartedOptions) { + const { defaultPath, onToastRef, setHiddenDefaults, switchVault } = options + + return useCallback(() => { + return restoreGettingStartedVault({ + defaultPath, + onToastRef, + setHiddenDefaults, + switchVault, + }) + }, [defaultPath, onToastRef, setHiddenDefaults, switchVault]) +} + +function useVaultActions({ + defaultPath, + defaultVaults, + extraVaults, + hiddenDefaults, + onSwitchRef, + onToastRef, + setExtraVaults, + setHiddenDefaults, + setVaultPath, +}: VaultActionOptions) { + const addVault = useCallback((path: string, label: string) => { + addVaultToList(setExtraVaults, path, label) + }, [setExtraVaults]) + + const switchVault = useSwitchVaultAction(onSwitchRef, setVaultPath) const addAndSwitch = useCallback((path: string, label: string) => { addVault(path, label) switchVault(path) }, [addVault, switchVault]) - const handleVaultCloned = useCallback((path: string, label: string) => { - addAndSwitch(path, label) - onToastRef.current(`Vault "${label}" cloned and opened`) - }, [addAndSwitch]) + return { + handleOpenLocalFolder: useOpenLocalFolderAction(addAndSwitch, onToastRef), + handleVaultCloned: useVaultClonedAction(addAndSwitch, onToastRef), + removeVault: useRemoveVaultAction({ + defaultVaults, + extraVaults, + hiddenDefaults, + onSwitchRef, + onToastRef, + setExtraVaults, + setHiddenDefaults, + setVaultPath, + }), + restoreGettingStarted: useRestoreGettingStartedAction({ + defaultPath, + onToastRef, + setHiddenDefaults, + switchVault, + }), + switchVault, + } +} - const handleOpenLocalFolder = useCallback(async () => { - const path = await pickFolder('Open vault folder') - if (!path) return - const label = labelFromPath(path) - addAndSwitch(path, label) - onToastRef.current(`Vault "${label}" opened`) - }, [addAndSwitch]) +async function restoreGettingStartedVault({ + defaultPath, + onToastRef, + setHiddenDefaults, + switchVault, +}: RestoreGettingStartedOptions) { + if (!defaultPath) { + onToastRef.current('Could not resolve the Getting Started vault path') + return + } - const removeVault = useCallback((path: string) => { - const isDefault = defaultVaults.some(v => v.path === path) - if (isDefault) { - setHiddenDefaults(prev => prev.includes(path) ? prev : [...prev, path]) - } else { - setExtraVaults(prev => prev.filter(v => v.path !== path)) - } + try { + await ensureGettingStartedVaultReady(defaultPath) + setHiddenDefaults(previousHidden => previousHidden.filter(path => path !== defaultPath)) + switchVault(defaultPath) + onToastRef.current('Getting Started vault ready') + } catch (err) { + onToastRef.current(formatGettingStartedRestoreError(err)) + } +} - // If removing the active vault, switch to the first remaining vault - setVaultPath(currentPath => { - if (currentPath !== path) return currentPath - const remaining = [ - ...defaultVaults.filter(v => v.path !== path && !(isDefault ? [] : hiddenDefaults).includes(v.path)), - ...extraVaults.filter(v => v.path !== path), - ] - if (remaining.length > 0) { - onSwitchRef.current() - return remaining[0].path - } - return currentPath - }) +/** Manages vault path, extra vaults, switching, cloning, and local folder opening. + * Vault list and active vault are persisted via Tauri backend to survive app updates. */ +export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions) { + const onSwitchRef = useRef(onSwitch) + const onToastRef = useRef(onToast) + useEffect(() => { onSwitchRef.current = onSwitch; onToastRef.current = onToast }) - const vault = [...defaultVaults, ...extraVaults].find(v => v.path === path) - onToastRef.current(`Vault "${vault?.label ?? labelFromPath(path)}" removed from list`) - }, [defaultVaults, extraVaults, hiddenDefaults]) - - const restoreGettingStarted = useCallback(async () => { - const gsPath = defaultPath - // Un-hide the Getting Started vault - setHiddenDefaults(prev => prev.filter(p => p !== gsPath)) - // Try to create the vault if it doesn't exist on disk - try { - const exists = await tauriCall('check_vault_exists', { path: gsPath }) - if (!exists) { - await tauriCall('create_getting_started_vault', { targetPath: gsPath }) - } - } catch { - // In mock/test mode, creation may fail — that's fine - } - switchVault(gsPath) - onToastRef.current('Getting Started vault restored') - }, [defaultPath, switchVault]) + const persistedState = usePersistedVaultState(onSwitchRef) + const { defaultPath, extraVaults, hiddenDefaults, loaded, vaultPath } = persistedState + const { allVaults, defaultVaults, isGettingStartedHidden } = useVaultCollections( + defaultPath, + hiddenDefaults, + extraVaults, + ) + const { handleOpenLocalFolder, handleVaultCloned, removeVault, restoreGettingStarted, switchVault } = useVaultActions({ + ...persistedState, + allVaults, + defaultVaults, + isGettingStartedHidden, + onSwitchRef, + onToastRef, + }) return { vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder, loaded, diff --git a/tests/smoke/offline-onboarding-status.spec.ts b/tests/smoke/offline-onboarding-status.spec.ts new file mode 100644 index 00000000..18c5fd0a --- /dev/null +++ b/tests/smoke/offline-onboarding-status.spec.ts @@ -0,0 +1,64 @@ +import { test, expect, type Page } from '@playwright/test' + +async function installOnboardingMocks(page: Page, offline: boolean) { + await page.addInitScript((isOffline: boolean) => { + localStorage.clear() + + let ref: Record | null = null + + Object.defineProperty(window, '__mockHandlers', { + configurable: true, + set(value) { + ref = value as Record + ref.load_vault_list = () => ({ + vaults: [], + active_vault: null, + hidden_defaults: [], + }) + ref.get_default_vault_path = () => '/Users/mock/Documents/Getting Started' + ref.check_vault_exists = () => false + ref.create_getting_started_vault = (args: { targetPath?: string | null }) => { + return args.targetPath || '/Users/mock/Documents/Getting Started' + } + }, + get() { + return ref + }, + }) + + Object.defineProperty(window, 'prompt', { + configurable: true, + value: () => '/Users/mock/Documents/Getting Started', + }) + + Object.defineProperty(window.navigator, 'onLine', { + configurable: true, + get: () => !isOffline, + }) + }, offline) +} + +test('offline onboarding disables template cloning and explains clone-later behavior', async ({ page }) => { + await installOnboardingMocks(page, true) + + await page.goto('/', { waitUntil: 'domcontentloaded' }) + + await expect(page.getByTestId('welcome-screen')).toBeVisible() + await expect(page.getByTestId('welcome-create-new')).toBeEnabled() + await expect(page.getByTestId('welcome-open-folder')).toBeEnabled() + await expect(page.getByTestId('welcome-create-vault')).toBeDisabled() + await expect(page.getByText('Requires internet — clone later. Suggested path: /Users/mock/Documents/Getting Started')).toBeVisible() +}) + +test('status bar keeps a Getting Started clone entry available after onboarding', async ({ page }) => { + await installOnboardingMocks(page, false) + + await page.goto('/', { waitUntil: 'domcontentloaded' }) + await expect(page.getByTestId('welcome-screen')).toBeVisible() + + await page.getByTestId('welcome-create-vault').click() + + await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible() + await page.getByTitle('Switch vault').click() + await expect(page.getByTestId('vault-menu-clone-getting-started')).toBeVisible() +})