feat: handle offline onboarding gracefully
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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)' }}>✦</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)' }}>✦</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"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
Reference in New Issue
Block a user