feat: handle offline onboarding gracefully

This commit is contained in:
lucaronin
2026-04-12 19:17:49 +02:00
parent e2745d96eb
commit 739e1b3c20
13 changed files with 850 additions and 166 deletions

View File

@@ -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 <WelcomeView onboarding={onboarding} />
return <WelcomeView onboarding={onboarding} isOffline={networkStatus.isOffline} />
}
// Show loading spinner while checking vault (skip for note windows)
@@ -737,7 +739,7 @@ function App() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onClickPending={() => 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} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={vaultSwitcher.restoreGettingStarted} onClickPending={() => 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} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette
@@ -782,7 +784,7 @@ function App() {
type OnboardingState = ReturnType<typeof useOnboarding>
/** 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 (
<div className="app-shell">
@@ -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}

View File

@@ -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(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
onCloneGettingStarted={vi.fn()}
/>
)
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(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
onCloneGettingStarted={onCloneGettingStarted}
/>
)
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(<StatusBar noteCount={100} modifiedCount={3} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
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(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} isOffline={true} />
)
expect(screen.getByTestId('status-offline')).toHaveTextContent('Offline')
})
it('calls onPullAndPush when clicking Pull required badge', () => {
const onPullAndPush = vi.fn()
render(

View File

@@ -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}

View File

@@ -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(<WelcomeScreen {...defaultProps} isOffline={true} />)
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(<WelcomeScreen {...defaultProps} onCreateNewVault={onCreateNewVault} />)

View File

@@ -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: <span style={{ fontSize: 28, color: 'var(--accent-blue)' }}>&#10022;</span>,
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: <AlertTriangle size={28} style={{ color: 'var(--accent-orange)' }} />,
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 (
<div style={CONTAINER_STYLE} data-testid="welcome-screen">
@@ -209,24 +251,16 @@ export function WelcomeScreen({
<div
style={{
...ICON_WRAP_STYLE,
background: isWelcome ? 'var(--accent-blue-light, #EBF4FF)' : 'var(--accent-yellow-light, #FFF3E0)',
background: presentation.heroBackground,
}}
>
{isWelcome
? <span style={{ fontSize: 28, color: 'var(--accent-blue)' }}>&#10022;</span>
: <AlertTriangle size={28} style={{ color: 'var(--accent-orange)' }} />
}
{presentation.heroIcon}
</div>
<div style={{ textAlign: 'center' }}>
<h1 style={TITLE_STYLE}>
{isWelcome ? 'Welcome to Tolaria' : 'Vault not found'}
</h1>
<h1 style={TITLE_STYLE}>{presentation.title}</h1>
<p style={{ ...SUBTITLE_STYLE, marginTop: 8 }}>
{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}
</p>
</div>
@@ -249,7 +283,7 @@ export function WelcomeScreen({
<OptionButton
icon={<FolderOpen size={18} style={{ color: 'var(--accent-green)' }} />}
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={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
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"
/>

View File

@@ -304,6 +304,33 @@ export function CommitBadge({ info }: { info: LastCommitInfo }) {
)
}
export function OfflineBadge({ isOffline }: { isOffline?: boolean }) {
if (!isOffline) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span
style={{
...ICON_STYLE,
color: 'var(--destructive, #e03e3e)',
background: 'rgba(224, 62, 62, 0.12)',
borderRadius: 999,
padding: '2px 6px',
fontWeight: 600,
}}
title="No internet connection"
data-testid="status-offline"
>
<span aria-hidden="true" style={{ fontSize: 10, lineHeight: 1 }}>
</span>
Offline
</span>
</>
)
}
export function SyncBadge({
status,
lastSyncTime,

View File

@@ -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}
/>
<span style={SEP_STYLE}>|</span>
@@ -104,6 +110,7 @@ export function StatusBarPrimarySection({
<Package size={13} />
{buildNumber ?? 'b?'}
</span>
<OfflineBadge isOffline={isOffline} />
<ChangesBadge count={modifiedCount} onClick={onClickPending} />
<CommitButton onClick={onCommitPush} />
<SyncBadge

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { AlertTriangle, Check, FolderOpen, GitBranch, X } from 'lucide-react'
import { AlertTriangle, Check, FolderOpen, GitBranch, Rocket, X } from 'lucide-react'
import type { VaultOption } from './types'
import { useDismissibleLayer } from './useDismissibleLayer'
@@ -10,6 +10,7 @@ interface VaultMenuProps {
onSwitchVault: (path: string) => 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<VaultMenuProps, 'onCloneGettingStarted' | 'onCloneVault' | 'onOpenLocalFolder'>): VaultAction[] {
const items: VaultAction[] = []
if (onOpenLocalFolder) {
items.push({
key: 'open-local',
icon: <FolderOpen size={12} />,
label: 'Open local folder',
testId: 'vault-menu-open-local',
onClick: onOpenLocalFolder,
})
}
if (onCloneVault) {
items.push({
key: 'clone-git',
icon: <GitBranch size={12} />,
label: 'Clone Git repo',
testId: 'vault-menu-clone-git',
onClick: onCloneVault,
})
}
if (onCloneGettingStarted) {
items.push({
key: 'clone-getting-started',
icon: <Rocket size={12} />,
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 <Check size={12} />
if (unavailable) return <AlertTriangle size={12} style={{ color: 'var(--muted-foreground)' }} />
@@ -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<VaultAction[]>(() => {
const items: VaultAction[] = []
if (onOpenLocalFolder) {
items.push({
key: 'open-local',
icon: <FolderOpen size={12} />,
label: 'Open local folder',
testId: 'vault-menu-open-local',
onClick: onOpenLocalFolder,
})
}
if (onCloneVault) {
items.push({
key: 'clone-git',
icon: <GitBranch size={12} />,
label: 'Clone Git repo',
testId: 'vault-menu-clone-git',
onClick: onCloneVault,
})
}
return items
}, [onCloneVault, onOpenLocalFolder])
return buildVaultActions({
onCloneGettingStarted,
onCloneVault,
onOpenLocalFolder,
})
}, [onCloneGettingStarted, onCloneVault, onOpenLocalFolder])
return (
<div ref={menuRef} style={{ position: 'relative' }}>

View File

@@ -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)
})
})

View File

@@ -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 }
}

View File

@@ -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<string, unknown>) => {
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', () => {

View File

@@ -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<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setVaultPath: Dispatch<SetStateAction<string>>
vaultPath: string
}
interface VaultCollections {
allVaults: VaultOption[]
defaultVaults: VaultOption[]
isGettingStartedHidden: boolean
}
interface PersistedVaultStore {
defaultPath: string
extraVaults: VaultOption[]
hiddenDefaults: string[]
loaded: boolean
setDefaultPath: Dispatch<SetStateAction<string>>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setLoaded: Dispatch<SetStateAction<boolean>>
setVaultPath: Dispatch<SetStateAction<string>>
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<SetStateAction<string[]>>
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<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setVaultPath: Dispatch<SetStateAction<string>>
}
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<T>(command: string, args: Record<string, unknown>): Promise<T
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(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<string> {
if (STATIC_DEFAULT_PATH) {
return STATIC_DEFAULT_PATH
}
try {
return await tauriCall<string>('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<SetStateAction<string>>,
) {
if (!resolvedDefaultPath) {
return
}
setDefaultPath(resolvedDefaultPath)
syncDefaultVaultExport(resolvedDefaultPath)
}
function applyInitialVaultTarget(
activeVault: string,
resolvedDefaultPath: string,
setVaultPath: Dispatch<SetStateAction<string>>,
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<VaultOption[]>([])
const [hiddenDefaults, setHiddenDefaults] = useState<string[]>([])
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<string>('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<void> {
const exists = await tauriCall<boolean>('check_vault_exists', { path })
if (!exists) {
await tauriCall<string>('create_getting_started_vault', { targetPath: path })
}
}
function addVaultToList(
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>,
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<SetStateAction<string>>,
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<SetStateAction<string>>,
) {
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<boolean>('check_vault_exists', { path: gsPath })
if (!exists) {
await tauriCall<string>('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,

View File

@@ -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<string, unknown> | null = null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = value as Record<string, unknown>
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()
})