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

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