Compare commits

...

5 Commits

Author SHA1 Message Date
lucaronin
fbbd53d204 test: restore smoke flows after Claude onboarding 2026-04-12 20:10:01 +02:00
lucaronin
fd9de04275 feat: add first-launch Claude Code onboarding 2026-04-12 19:56:10 +02:00
lucaronin
a13e36a504 feat: support local-only git commits without remotes 2026-04-12 19:38:25 +02:00
lucaronin
73a63085c7 fix: tighten vault switcher typing 2026-04-12 19:19:52 +02:00
lucaronin
739e1b3c20 feat: handle offline onboarding gracefully 2026-04-12 19:17:49 +02:00
32 changed files with 1697 additions and 226 deletions

View File

@@ -336,6 +336,13 @@ interface ModifiedFile {
status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
}
interface GitRemoteStatus {
branch: string
ahead: number
behind: number
hasRemote: boolean
}
interface PulseCommit {
hash: string
shortHash: string
@@ -372,12 +379,18 @@ interface PulseCommit {
- `pullAndPush()`: pulls then auto-pushes for divergence recovery
- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs)
`useGitRemoteStatus` is the commit-time companion to `useAutoSync`:
- Re-checks `git_remote_status` when the Commit dialog opens and right before submit
- Converts `hasRemote: false` into a local-only commit path
- Keeps the normal push path unchanged for vaults that do have a remote
### Frontend Integration
- **Modified file badges**: Orange dots in sidebar
- **Diff view**: Toggle in breadcrumb bar → shows unified diff
- **Git history**: Shown in Inspector panel for active note
- **Commit dialog**: Triggered from sidebar or Cmd+K
- **No remote indicator**: Neutral chip in the bottom bar when `GitRemoteStatus.hasRemote === false`
- **Pulse view**: Activity feed when Pulse filter is selected
- **Pull command**: Cmd+K → "Pull from Remote", also in Vault menu
- **Git status popup**: Click sync badge → shows branch, ahead/behind, Pull button
@@ -514,6 +527,11 @@ Per-vault settings stored locally and scoped by vault path:
- User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen folder
- Welcome state tracked in localStorage (`tolaria_welcome_dismissed`, with legacy fallback)
`useClaudeCodeOnboarding(enabled)` adds a separate first-launch agent step:
- Reads a local dismissal flag for the Claude Code prompt
- Only shows after vault onboarding has already resolved to a ready state
- Persists dismissal locally once the user continues
### Remote Git Operations
Tolaria delegates remote auth to the user's system git setup:

View File

@@ -432,6 +432,8 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
- **Open an existing folder** → system file picker
- **Get started with a template** → pick a folder, then call `create_getting_started_vault()` to clone the public starter repo at runtime
Once a vault is ready, `useClaudeCodeOnboarding` can show a one-time `ClaudeCodeOnboardingPrompt`. That prompt reuses `useClaudeCodeStatus` so first launch surfaces whether the `claude` CLI is installed, offers the install link when it is missing, and stores local dismissal so the prompt does not repeat on every launch.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` only holds the public starter repo URL and delegates the actual clone to the git backend.
### Remote Clone & Auth Model
@@ -522,8 +524,13 @@ flowchart TD
PC -->|Fast-forward| RV["reload vault"]
PC -->|Up to date| DONE["idle"]
MAN["Manual commit\n(CommitDialog)"] --> GC["invoke('git_commit', message)"]
GC --> GP["invoke('git_push')"]
MAN["Manual commit\n(CommitDialog)"] --> RS["useGitRemoteStatus\n(commit-time check)"]
RS --> RCHK["invoke('git_remote_status')"]
RCHK --> RMODE{Remote configured?}
RMODE -->|No| GC["invoke('git_commit', message)"]
GC --> LOCAL["Local commit only\nNo remote chip + local toast"]
RMODE -->|Yes| GC2["invoke('git_commit', message)"]
GC2 --> GP["invoke('git_push')"]
GP --> PR{Push result?}
PR -->|ok| RM["Reload modified files"]
PR -->|rejected| DIV["syncStatus = pull_required"]
@@ -536,6 +543,8 @@ flowchart TD
STATUS["Click sync badge"] --> POPUP["GitStatusPopup\n(branch, ahead/behind)"]
```
`useGitRemoteStatus` re-checks `git_remote_status` when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps the flow local-only: the status bar shows a neutral `No remote` chip, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted.
#### Sync States
| State | Indicator | Color | Trigger |
@@ -696,6 +705,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useTheme` | Editor theme CSS vars | Editor typography theme |
| `useAiAgent` | `messages`, `status`, tool actions | AI agent conversation |
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
| `useSettings` | App settings (telemetry, release channel, auto-sync interval) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |

View File

@@ -1,18 +1,32 @@
import { defineConfig } from '@playwright/test'
const baseURL = process.env.BASE_URL || 'http://localhost:5201'
const claudeCodeOnboardingStorageState = {
cookies: [],
origins: [
{
origin: baseURL,
localStorage: [
{ name: 'tolaria:claude-code-onboarding-dismissed', value: '1' },
],
},
],
}
export default defineConfig({
testDir: './tests/smoke',
timeout: 20_000,
retries: 2,
workers: 1,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:5201',
baseURL,
headless: true,
storageState: claudeCodeOnboardingStorageState,
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
webServer: {
command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5201'}`,
url: process.env.BASE_URL || 'http://localhost:5201',
url: baseURL,
reuseExistingServer: true,
},
})

View File

@@ -3,6 +3,17 @@ import { defineConfig } from '@playwright/test'
const baseURL = process.env.BASE_URL || 'http://127.0.0.1:41741'
const port = new URL(baseURL).port || '41741'
const reuseExistingServer = process.env.PLAYWRIGHT_REUSE_SERVER === '1'
const claudeCodeOnboardingStorageState = {
cookies: [],
origins: [
{
origin: baseURL,
localStorage: [
{ name: 'tolaria:claude-code-onboarding-dismissed', value: '1' },
],
},
],
}
export default defineConfig({
testDir: './tests',
@@ -13,6 +24,7 @@ export default defineConfig({
use: {
baseURL,
headless: true,
storageState: claudeCodeOnboardingStorageState,
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
webServer: {

View File

@@ -123,6 +123,8 @@ vi.mock('@blocknote/mantine/style.css', () => ({}))
import App from './App'
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -133,6 +135,7 @@ describe('App', () => {
localStorage.removeItem('laputa-view-mode')
localStorage.removeItem('tolaria_welcome_dismissed')
localStorage.removeItem('laputa_welcome_dismissed')
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
})
it('renders the four-panel layout', async () => {

View File

@@ -16,15 +16,18 @@ import { StatusBar } from './components/StatusBar'
import { SettingsPanel } from './components/SettingsPanel'
import { CloneVaultModal } from './components/CloneVaultModal'
import { WelcomeScreen } from './components/WelcomeScreen'
import { ClaudeCodeOnboardingPrompt } from './components/ClaudeCodeOnboardingPrompt'
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
import { FeedbackDialog } from './components/FeedbackDialog'
import { useTelemetry } from './hooks/useTelemetry'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useClaudeCodeOnboarding } from './hooks/useClaudeCodeOnboarding'
import { useClaudeCodeStatus } from './hooks/useClaudeCodeStatus'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
import { useNoteActions } from './hooks/useNoteActions'
import { useCommitFlow } from './hooks/useCommitFlow'
import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
import { useViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
import { useAppCommands } from './hooks/useAppCommands'
@@ -39,6 +42,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 +106,7 @@ function App() {
const [showFeedback, setShowFeedback] = useState(false)
const openFeedback = useCallback(() => setShowFeedback(true), [])
const closeFeedback = useCallback(() => setShowFeedback(false), [])
const networkStatus = useNetworkStatus()
useEffect(() => {
const handleOpenAiChat = () => {
@@ -121,6 +126,8 @@ function App() {
})
const onboarding = useOnboarding(vaultSwitcher.vaultPath)
const { status: claudeCodeStatus, version: claudeCodeVersion } = useClaudeCodeStatus()
const claudeCodeOnboarding = useClaudeCodeOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
// When onboarding resolves to a different vault path, update the switcher
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
@@ -171,7 +178,6 @@ function App() {
}
}, [vault.entries.length, gitRepoState, resolvedPath])
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
const { status: claudeCodeStatus, version: claudeCodeVersion } = useClaudeCodeStatus()
const autoSync = useAutoSync({
vaultPath: resolvedPath,
@@ -183,6 +189,7 @@ function App() {
},
onToast: (msg) => setToastMessage(msg),
})
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
// Detect external file renames on window focus
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
@@ -416,7 +423,14 @@ function App() {
}
}, [vault, notes, setToastMessage])
const commitFlow = useCommitFlow({ savePending: appSave.savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected })
const commitFlow = useCommitFlow({
savePending: appSave.savePending,
loadModifiedFiles: vault.loadModifiedFiles,
resolveRemoteStatus: gitRemoteStatus.refreshRemoteStatus,
setToastMessage,
onPushRejected: autoSync.handlePushRejected,
vaultPath: resolvedPath,
})
const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles])
const entryActions = useEntryActions({
@@ -621,7 +635,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)
@@ -629,6 +643,15 @@ function App() {
return <LoadingView />
}
if (!noteWindowParams && onboarding.state.status === 'ready' && claudeCodeOnboarding.showPrompt) {
return (
<ClaudeCodeOnboardingView
status={claudeCodeStatus}
onContinue={claudeCodeOnboarding.dismissPrompt}
/>
)
}
// Show git-required modal when vault has no git repo (skip for note windows)
if (!noteWindowParams && gitRepoState === 'required') {
return (
@@ -737,7 +760,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
@@ -750,7 +773,14 @@ function App() {
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} suggestedMessage={suggestedCommitMessage} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
<CommitDialog
open={commitFlow.showCommitDialog}
modifiedCount={vault.modifiedFiles.length}
commitMode={commitFlow.commitMode}
suggestedMessage={suggestedCommitMessage}
onCommit={commitFlow.handleCommitPush}
onClose={commitFlow.closeCommitDialog}
/>
<ConflictResolverModal
open={dialogs.showConflictResolver}
fileStates={conflictResolver.fileStates}
@@ -782,7 +812,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 +824,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}
@@ -802,6 +833,20 @@ function WelcomeView({ onboarding }: { onboarding: OnboardingState }) {
)
}
function ClaudeCodeOnboardingView({
status,
onContinue,
}: {
status: ReturnType<typeof useClaudeCodeStatus>['status']
onContinue: () => void
}) {
return (
<div className="app-shell">
<ClaudeCodeOnboardingPrompt status={status} onContinue={onContinue} />
</div>
)
}
/** Loading spinner view - extracted from main App component */
function LoadingView() {
return (

View File

@@ -0,0 +1,56 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ClaudeCodeOnboardingPrompt } from './ClaudeCodeOnboardingPrompt'
const openExternalUrl = vi.fn()
vi.mock('../utils/url', () => ({
openExternalUrl: (...args: unknown[]) => openExternalUrl(...args),
}))
describe('ClaudeCodeOnboardingPrompt', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows the detected state with a continue action', () => {
render(<ClaudeCodeOnboardingPrompt status="installed" onContinue={vi.fn()} />)
expect(screen.getByText('Claude Code detected')).toBeInTheDocument()
expect(screen.getByTestId('claude-onboarding-continue')).toHaveTextContent('Continue')
expect(screen.queryByTestId('claude-onboarding-install')).not.toBeInTheDocument()
})
it('shows the install path when Claude Code is missing', () => {
render(<ClaudeCodeOnboardingPrompt status="missing" onContinue={vi.fn()} />)
expect(screen.getByText('Claude Code not detected')).toBeInTheDocument()
expect(screen.getByText('Install Claude Code to enable AI-powered note management.')).toBeInTheDocument()
expect(screen.getByTestId('claude-onboarding-install')).toBeInTheDocument()
expect(screen.getByTestId('claude-onboarding-continue')).toHaveTextContent('Continue without it')
})
it('opens the Claude Code install page', () => {
render(<ClaudeCodeOnboardingPrompt status="missing" onContinue={vi.fn()} />)
fireEvent.click(screen.getByTestId('claude-onboarding-install'))
expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
})
it('calls onContinue from the detected state', () => {
const onContinue = vi.fn()
render(<ClaudeCodeOnboardingPrompt status="installed" onContinue={onContinue} />)
fireEvent.click(screen.getByTestId('claude-onboarding-continue'))
expect(onContinue).toHaveBeenCalledOnce()
})
it('disables continue while detection is still running', () => {
render(<ClaudeCodeOnboardingPrompt status="checking" onContinue={vi.fn()} />)
expect(screen.getByText('Checking for Claude Code')).toBeInTheDocument()
expect(screen.getByTestId('claude-onboarding-continue')).toBeDisabled()
})
})

View File

@@ -0,0 +1,101 @@
import { ArrowUpRight, Bot, CheckCircle2, Loader2 } from 'lucide-react'
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
import { openExternalUrl } from '../utils/url'
import { Button } from './ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card'
const CLAUDE_CODE_INSTALL_URL = 'https://docs.anthropic.com/en/docs/claude-code'
interface ClaudeCodeOnboardingPromptProps {
status: ClaudeCodeStatus
onContinue: () => void
}
function getPromptCopy(status: ClaudeCodeStatus) {
if (status === 'installed') {
return {
accentClassName: 'bg-emerald-100 text-emerald-700',
description: "Tolaria's AI features are ready to use.",
icon: <CheckCircle2 className="size-7" />,
title: 'Claude Code detected',
}
}
if (status === 'missing') {
return {
accentClassName: 'bg-amber-100 text-amber-700',
description: 'Tolaria works best with an AI coding agent installed.',
icon: <Bot className="size-7" />,
title: 'Claude Code not detected',
}
}
return {
accentClassName: 'bg-slate-100 text-slate-600',
description: 'Checking whether Claude Code is available on this machine.',
icon: <Loader2 className="size-7 animate-spin" />,
title: 'Checking for Claude Code',
}
}
export function ClaudeCodeOnboardingPrompt({
status,
onContinue,
}: ClaudeCodeOnboardingPromptProps) {
const copy = getPromptCopy(status)
return (
<div
className="flex h-full w-full items-center justify-center bg-sidebar px-6 py-10"
data-testid="claude-onboarding-screen"
>
<Card className="w-full max-w-2xl border-border bg-background shadow-sm">
<CardHeader className="items-center gap-5 text-center">
<div className={`flex size-16 items-center justify-center rounded-2xl ${copy.accentClassName}`}>
{copy.icon}
</div>
<div className="space-y-2">
<CardTitle className="text-3xl tracking-tight">
{copy.title}
</CardTitle>
<p className="text-sm leading-6 text-muted-foreground" data-testid="claude-onboarding-description">
{status === 'installed' && '✅ '}
{status === 'missing' && '🤖 '}
{copy.description}
</p>
</div>
</CardHeader>
<CardContent className="space-y-3 text-center">
{status === 'missing' && (
<p className="text-sm leading-6 text-muted-foreground">
Install Claude Code to enable AI-powered note management.
</p>
)}
</CardContent>
<CardFooter className="justify-center gap-3">
{status === 'missing' && (
<Button
type="button"
variant="outline"
onClick={() => void openExternalUrl(CLAUDE_CODE_INSTALL_URL)}
data-testid="claude-onboarding-install"
>
Install Claude Code
<ArrowUpRight className="size-4" />
</Button>
)}
<Button
type="button"
onClick={onContinue}
disabled={status === 'checking'}
data-testid="claude-onboarding-continue"
>
{status === 'missing' ? 'Continue without it' : status === 'installed' ? 'Continue' : 'Checking…'}
</Button>
</CardFooter>
</Card>
</div>
)
}

View File

@@ -10,9 +10,8 @@ describe('CommitDialog', () => {
vi.clearAllMocks()
})
function getCommitButton() {
// "Commit & Push" appears in both dialog title and button — use role to disambiguate
return screen.getByRole('button', { name: 'Commit & Push' })
function getActionButton(name = 'Commit & Push') {
return screen.getByRole('button', { name })
}
it('shows file count badge', () => {
@@ -27,21 +26,21 @@ describe('CommitDialog', () => {
it('disables Commit button when message is empty', () => {
render(<CommitDialog open={true} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
expect(getCommitButton()).toBeDisabled()
expect(getActionButton()).toBeDisabled()
})
it('enables Commit button when message is typed', () => {
render(<CommitDialog open={true} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
const textarea = screen.getByPlaceholderText('Commit message...')
fireEvent.change(textarea, { target: { value: 'fix: bug fix' } })
expect(getCommitButton()).not.toBeDisabled()
expect(getActionButton()).not.toBeDisabled()
})
it('calls onCommit with trimmed message on button click', () => {
render(<CommitDialog open={true} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
const textarea = screen.getByPlaceholderText('Commit message...')
fireEvent.change(textarea, { target: { value: ' fix: bug fix ' } })
fireEvent.click(getCommitButton())
fireEvent.click(getActionButton())
expect(onCommit).toHaveBeenCalledWith('fix: bug fix')
})
@@ -70,7 +69,7 @@ describe('CommitDialog', () => {
render(<CommitDialog open={true} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
const textarea = screen.getByPlaceholderText('Commit message...')
fireEvent.change(textarea, { target: { value: ' ' } })
fireEvent.click(getCommitButton())
fireEvent.click(getActionButton())
expect(onCommit).not.toHaveBeenCalled()
})
@@ -87,7 +86,7 @@ describe('CommitDialog', () => {
it('enables Commit button when suggestedMessage is provided', () => {
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
expect(getCommitButton()).not.toBeDisabled()
expect(getActionButton()).not.toBeDisabled()
})
it('submits suggestedMessage on Cmd+Enter without user edits', () => {
@@ -101,7 +100,16 @@ describe('CommitDialog', () => {
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
const textarea = screen.getByPlaceholderText('Commit message...')
fireEvent.change(textarea, { target: { value: 'fix: corrected typo in alpha' } })
fireEvent.click(getCommitButton())
fireEvent.click(getActionButton())
expect(onCommit).toHaveBeenCalledWith('fix: corrected typo in alpha')
})
it('switches to local-only copy when commitMode is local', () => {
render(<CommitDialog open={true} modifiedCount={2} commitMode="local" onCommit={onCommit} onClose={onClose} />)
expect(screen.getByRole('heading', { name: 'Commit' })).toBeInTheDocument()
expect(screen.getByText('This vault has no git remote configured. Tolaria will create a local commit only.')).toBeInTheDocument()
expect(screen.getByText('Cmd+Enter to commit locally')).toBeInTheDocument()
expect(getActionButton('Commit')).toBeDisabled()
})
})

View File

@@ -2,18 +2,66 @@ import { useState, useEffect, useRef } from 'react'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import type { CommitMode } from '../hooks/useCommitFlow'
type CommitDialogCopy = {
title: string
description: string
actionLabel: string
shortcutHint: string
}
function getDialogCopy(commitMode: CommitMode): CommitDialogCopy {
if (commitMode === 'local') {
return {
title: 'Commit',
description: 'This vault has no git remote configured. Tolaria will create a local commit only.',
actionLabel: 'Commit',
shortcutHint: 'Cmd+Enter to commit locally',
}
}
return {
title: 'Commit & Push',
description: 'Review changed files and enter a commit message before committing and pushing.',
actionLabel: 'Commit & Push',
shortcutHint: 'Cmd+Enter to commit',
}
}
function changedFilesLabel(modifiedCount: number): string {
return `${modifiedCount} file${modifiedCount !== 1 ? 's' : ''} changed`
}
function isSubmitShortcut(event: React.KeyboardEvent): boolean {
return event.key === 'Enter' && (event.metaKey || event.ctrlKey)
}
function isCloseShortcut(event: React.KeyboardEvent): boolean {
return event.key === 'Escape'
}
interface CommitDialogProps {
open: boolean
modifiedCount: number
commitMode?: CommitMode
suggestedMessage?: string
onCommit: (message: string) => void
onClose: () => void
}
export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit, onClose }: CommitDialogProps) {
export function CommitDialog({
open,
modifiedCount,
commitMode = 'push',
suggestedMessage,
onCommit,
onClose,
}: CommitDialogProps) {
const [message, setMessage] = useState('')
const inputRef = useRef<HTMLTextAreaElement>(null)
const copy = getDialogCopy(commitMode)
useEffect(() => {
if (open) {
@@ -29,10 +77,10 @@ export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit,
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
if (isSubmitShortcut(e)) {
e.preventDefault()
handleSubmit()
} else if (e.key === 'Escape') {
} else if (isCloseShortcut(e)) {
onClose()
}
}
@@ -42,18 +90,16 @@ export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit,
<DialogContent showCloseButton={false} className="sm:max-w-[420px]">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle>Commit & Push</DialogTitle>
<DialogTitle>{copy.title}</DialogTitle>
<Badge variant="secondary" className="text-xs">
{modifiedCount} file{modifiedCount !== 1 ? 's' : ''} changed
{changedFilesLabel(modifiedCount)}
</Badge>
</div>
<DialogDescription className="sr-only">
Review changed files and enter a commit message before committing and pushing.
</DialogDescription>
<DialogDescription>{copy.description}</DialogDescription>
</DialogHeader>
<textarea
<Textarea
ref={inputRef}
className="w-full resize-y rounded-lg border border-input bg-[var(--bg-input)] px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:border-ring"
className="min-h-[84px] resize-y bg-[var(--bg-input)] py-2.5 text-[13px]"
placeholder="Commit message..."
value={message}
onChange={(e) => setMessage(e.target.value)}
@@ -61,13 +107,13 @@ export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit,
rows={3}
/>
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
<span className="text-[11px] text-muted-foreground">Cmd+Enter to commit</span>
<span className="text-[11px] text-muted-foreground">{copy.shortcutHint}</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!message.trim()}>
Commit & Push
{copy.actionLabel}
</Button>
</div>
</DialogFooter>

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,26 @@ 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('shows a no-remote chip when the active git vault has no remote', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
/>
)
expect(screen.getByTestId('status-no-remote')).toHaveTextContent('No remote')
})
it('calls onPullAndPush when clicking Pull required badge', () => {
const onPullAndPush = vi.fn()
render(
@@ -355,6 +407,21 @@ describe('StatusBar', () => {
expect(onCommitPush).toHaveBeenCalledOnce()
})
it('uses a local-only tooltip for the commit button when no remote is configured', () => {
render(
<StatusBar
noteCount={100}
modifiedCount={5}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
onCommitPush={vi.fn()}
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
/>
)
expect(screen.getByTestId('status-commit-push')).toHaveAttribute('title', 'Commit locally (no remote configured)')
})
it('shows Commit button even when no modified files', () => {
render(<StatusBar noteCount={100} modifiedCount={0} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCommitPush={vi.fn()} />)
expect(screen.getByTestId('status-commit-push')).toBeInTheDocument()

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

@@ -122,6 +122,16 @@ function hasRemote(remoteStatus: GitRemoteStatus | null): boolean {
return remoteStatus?.hasRemote ?? false
}
function isRemoteMissing(remoteStatus: GitRemoteStatus | null | undefined): boolean {
return remoteStatus?.hasRemote === false
}
function commitButtonTitle(remoteStatus: GitRemoteStatus | null | undefined): string {
return isRemoteMissing(remoteStatus)
? 'Commit locally (no remote configured)'
: 'Commit & Push'
}
function getMcpBadgeConfig(status: McpStatus, onInstall?: () => void) {
if (status === 'installed' || status === 'checking') return null
const clickable = status === 'not_installed' && Boolean(onInstall)
@@ -304,6 +314,58 @@ 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 NoRemoteBadge({ remoteStatus }: { remoteStatus?: GitRemoteStatus | null }) {
if (!isRemoteMissing(remoteStatus)) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span
style={{
...ICON_STYLE,
color: 'var(--muted-foreground)',
background: 'var(--hover)',
borderRadius: 999,
padding: '2px 6px',
fontWeight: 600,
}}
title="This git vault has no remote configured. Commits stay local until you add one."
data-testid="status-no-remote"
>
<GitBranch size={12} />
No remote
</span>
</>
)
}
export function SyncBadge({
status,
lastSyncTime,
@@ -432,7 +494,13 @@ export function ChangesBadge({ count, onClick }: { count: number; onClick?: () =
)
}
export function CommitButton({ onClick }: { onClick?: () => void }) {
export function CommitButton({
onClick,
remoteStatus,
}: {
onClick?: () => void
remoteStatus?: GitRemoteStatus | null
}) {
if (!onClick) return null
return (
@@ -442,7 +510,7 @@ export function CommitButton({ onClick }: { onClick?: () => void }) {
role="button"
onClick={onClick}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Commit & Push"
title={commitButtonTitle(remoteStatus)}
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
data-testid="status-commit-push"

View File

@@ -11,6 +11,8 @@ import {
ConflictBadge,
ChangesBadge,
McpBadge,
NoRemoteBadge,
OfflineBadge,
PulseBadge,
SyncBadge,
} from './StatusBarBadges'
@@ -25,9 +27,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 +65,11 @@ export function StatusBarPrimarySection({
onSwitchVault,
onOpenLocalFolder,
onCloneVault,
onCloneGettingStarted,
onClickPending,
onClickPulse,
onCommitPush,
isOffline = false,
isGitVault = false,
syncStatus,
lastSyncTime,
@@ -89,6 +95,7 @@ export function StatusBarPrimarySection({
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onRemoveVault={onRemoveVault}
/>
<span style={SEP_STYLE}>|</span>
@@ -104,8 +111,10 @@ export function StatusBarPrimarySection({
<Package size={13} />
{buildNumber ?? 'b?'}
</span>
<OfflineBadge isOffline={isOffline} />
<NoRemoteBadge remoteStatus={remoteStatus} />
<ChangesBadge count={modifiedCount} onClick={onClickPending} />
<CommitButton onClick={onCommitPush} />
<CommitButton onClick={onCommitPush} remoteStatus={remoteStatus} />
<SyncBadge
status={syncStatus}
lastSyncTime={lastSyncTime}

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,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => (
<textarea
ref={ref}
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex min-h-20 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
),
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -0,0 +1,54 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { useClaudeCodeOnboarding } from './useClaudeCodeOnboarding'
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
const DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
describe('useClaudeCodeOnboarding', () => {
beforeEach(() => {
localStorage.clear()
})
it('shows the prompt when enabled and not dismissed', () => {
const { result } = renderHook(() => useClaudeCodeOnboarding(true))
expect(result.current.showPrompt).toBe(true)
})
it('hides the prompt when onboarding is disabled', () => {
const { result } = renderHook(() => useClaudeCodeOnboarding(false))
expect(result.current.showPrompt).toBe(false)
})
it('starts hidden when the prompt was already dismissed', () => {
localStorage.setItem(DISMISSED_KEY, '1')
const { result } = renderHook(() => useClaudeCodeOnboarding(true))
expect(result.current.showPrompt).toBe(false)
})
it('persists dismissal and hides the prompt', () => {
const { result } = renderHook(() => useClaudeCodeOnboarding(true))
act(() => {
result.current.dismissPrompt()
})
expect(result.current.showPrompt).toBe(false)
expect(localStorage.getItem(DISMISSED_KEY)).toBe('1')
})
})

View File

@@ -0,0 +1,33 @@
import { useCallback, useState } from 'react'
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
function wasDismissed(): boolean {
try {
return localStorage.getItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY) === '1'
} catch {
return false
}
}
function markDismissed(): void {
try {
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
} catch {
// localStorage may be unavailable in restricted contexts
}
}
export function useClaudeCodeOnboarding(enabled: boolean) {
const [dismissed, setDismissed] = useState(() => wasDismissed())
const dismissPrompt = useCallback(() => {
markDismissed()
setDismissed(true)
}, [])
return {
dismissPrompt,
showPrompt: enabled && !dismissed,
}
}

View File

@@ -2,28 +2,58 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCommitFlow } from './useCommitFlow'
const mockInvokeFn = vi.fn()
const mockTrackEvent = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({
invoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
}))
vi.mock('../lib/telemetry', () => ({
trackEvent: (event: string, properties?: Record<string, unknown>) => mockTrackEvent(event, properties),
}))
describe('useCommitFlow', () => {
let savePending: vi.Mock
let loadModifiedFiles: vi.Mock
let commitAndPush: vi.Mock
let resolveRemoteStatus: vi.Mock
let setToastMessage: vi.Mock
let onPushRejected: vi.Mock
beforeEach(() => {
savePending = vi.fn().mockResolvedValue(undefined)
loadModifiedFiles = vi.fn().mockResolvedValue(undefined)
commitAndPush = vi.fn().mockResolvedValue({ status: 'ok', message: 'Pushed to remote' })
resolveRemoteStatus = vi.fn().mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: true })
setToastMessage = vi.fn()
onPushRejected = vi.fn()
mockTrackEvent.mockReset()
mockInvokeFn.mockReset()
mockInvokeFn.mockImplementation((command: string) => {
if (command === 'git_commit') return Promise.resolve('[main abc1234] test commit')
if (command === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' })
throw new Error(`Unexpected command: ${command}`)
})
})
function renderCommitFlow() {
return renderHook(() => useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage, onPushRejected }))
return renderHook(() => useCommitFlow({
savePending,
loadModifiedFiles,
resolveRemoteStatus,
setToastMessage,
onPushRejected,
vaultPath: '/vault',
}))
}
it('openCommitDialog saves pending, refreshes files, then opens dialog', async () => {
it('openCommitDialog saves pending, refreshes files, and sets local mode when no remote exists', async () => {
resolveRemoteStatus.mockResolvedValueOnce({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
const { result } = renderCommitFlow()
expect(result.current.showCommitDialog).toBe(false)
await act(async () => {
await result.current.openCommitDialog()
@@ -31,10 +61,12 @@ describe('useCommitFlow', () => {
expect(savePending).toHaveBeenCalledTimes(1)
expect(loadModifiedFiles).toHaveBeenCalledTimes(1)
expect(resolveRemoteStatus).toHaveBeenCalledTimes(1)
expect(result.current.showCommitDialog).toBe(true)
expect(result.current.commitMode).toBe('local')
})
it('handleCommitPush saves pending, commits, shows toast, and refreshes files', async () => {
it('handleCommitPush commits and pushes when a remote is configured', async () => {
const { result } = renderCommitFlow()
await act(async () => {
@@ -42,14 +74,39 @@ describe('useCommitFlow', () => {
})
expect(savePending).toHaveBeenCalled()
expect(commitAndPush).toHaveBeenCalledWith('test message')
expect(mockInvokeFn).toHaveBeenNthCalledWith(1, 'git_commit', { vaultPath: '/vault', message: 'test message' })
expect(mockInvokeFn).toHaveBeenNthCalledWith(2, 'git_push', { vaultPath: '/vault' })
expect(setToastMessage).toHaveBeenCalledWith('Committed and pushed')
expect(loadModifiedFiles).toHaveBeenCalled()
expect(resolveRemoteStatus).toHaveBeenCalledTimes(2)
expect(mockTrackEvent).toHaveBeenCalledWith('commit_made', undefined)
expect(result.current.showCommitDialog).toBe(false)
})
it('handleCommitPush commits locally and skips push when no remote is configured', async () => {
resolveRemoteStatus.mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
mockInvokeFn.mockImplementation((command: string) => {
if (command === 'git_commit') return Promise.resolve('[main abc1234] test message')
throw new Error(`Unexpected command: ${command}`)
})
const { result } = renderCommitFlow()
await act(async () => {
await result.current.handleCommitPush('test message')
})
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
expect(mockInvokeFn).toHaveBeenCalledWith('git_commit', { vaultPath: '/vault', message: 'test message' })
expect(setToastMessage).toHaveBeenCalledWith('Committed locally (no remote configured)')
expect(onPushRejected).not.toHaveBeenCalled()
})
it('handleCommitPush calls onPushRejected when push is rejected', async () => {
commitAndPush.mockResolvedValueOnce({ status: 'rejected', message: 'Push rejected' })
mockInvokeFn.mockImplementation((command: string) => {
if (command === 'git_commit') return Promise.resolve('[main abc1234] test message')
if (command === 'git_push') return Promise.resolve({ status: 'rejected', message: 'Push rejected' })
throw new Error(`Unexpected command: ${command}`)
})
const { result } = renderCommitFlow()
await act(async () => {
@@ -61,7 +118,7 @@ describe('useCommitFlow', () => {
})
it('handleCommitPush shows error toast on failure', async () => {
commitAndPush.mockRejectedValueOnce(new Error('push failed'))
mockInvokeFn.mockImplementation(() => Promise.reject(new Error('push failed')))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderCommitFlow()
@@ -69,7 +126,7 @@ describe('useCommitFlow', () => {
await result.current.handleCommitPush('test')
})
expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('Commit failed'))
expect(setToastMessage).toHaveBeenCalledWith('Commit failed: push failed')
consoleSpy.mockRestore()
})

View File

@@ -1,47 +1,115 @@
import { useCallback, useState } from 'react'
import type { GitPushResult } from '../types'
import { invoke } from '@tauri-apps/api/core'
import type { GitPushResult, GitRemoteStatus } from '../types'
import { trackEvent } from '../lib/telemetry'
import { isTauri, mockInvoke } from '../mock-tauri'
export type CommitMode = 'push' | 'local'
interface LocalCommitResult {
status: 'local_only'
message: string
}
type CommitResult = GitPushResult | LocalCommitResult
interface CommitFlowConfig {
savePending: () => Promise<void | boolean>
loadModifiedFiles: () => Promise<void>
commitAndPush: (message: string) => Promise<GitPushResult>
resolveRemoteStatus: () => Promise<GitRemoteStatus | null>
setToastMessage: (msg: string | null) => void
onPushRejected?: () => void
vaultPath: string
}
/** Manages the Commit & Push dialog state and the save→commit→push flow. */
export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage, onPushRejected }: CommitFlowConfig) {
function commitModeFromRemoteStatus(remoteStatus: GitRemoteStatus | null): CommitMode {
return remoteStatus?.hasRemote === false ? 'local' : 'push'
}
async function commitLocally(vaultPath: string, message: string): Promise<void> {
if (!isTauri()) {
await mockInvoke<string>('git_commit', { vaultPath, message })
return
}
await invoke<string>('git_commit', { vaultPath, message })
}
async function pushCommittedChanges(vaultPath: string): Promise<GitPushResult> {
if (!isTauri()) {
return mockInvoke<GitPushResult>('git_push', { vaultPath })
}
return invoke<GitPushResult>('git_push', { vaultPath })
}
async function executeCommitAction(vaultPath: string, message: string, commitMode: CommitMode): Promise<CommitResult> {
await commitLocally(vaultPath, message)
if (commitMode === 'local') {
return { status: 'local_only', message: 'Committed locally (no remote configured)' }
}
return pushCommittedChanges(vaultPath)
}
function commitToastMessage(result: CommitResult): string {
if (result.status === 'ok') return 'Committed and pushed'
if (result.status === 'local_only') return result.message
if (result.status === 'rejected') return 'Committed, but push rejected — remote has new commits. Pull first.'
return result.message
}
function isPushRejected(result: CommitResult): boolean {
return result.status === 'rejected'
}
function formatCommitError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** Manages the commit dialog state and the save→commit→push/local flow. */
export function useCommitFlow({
savePending,
loadModifiedFiles,
resolveRemoteStatus,
setToastMessage,
onPushRejected,
vaultPath,
}: CommitFlowConfig) {
const [showCommitDialog, setShowCommitDialog] = useState(false)
const [commitMode, setCommitMode] = useState<CommitMode>('push')
const openCommitDialog = useCallback(async () => {
await savePending()
await loadModifiedFiles()
const remoteStatus = await resolveRemoteStatus()
setCommitMode(commitModeFromRemoteStatus(remoteStatus))
setShowCommitDialog(true)
}, [savePending, loadModifiedFiles])
}, [loadModifiedFiles, resolveRemoteStatus, savePending])
const handleCommitPush = useCallback(async (message: string) => {
setShowCommitDialog(false)
try {
await savePending()
const result = await commitAndPush(message)
if (result.status === 'ok') {
trackEvent('commit_made')
setToastMessage('Committed and pushed')
} else if (result.status === 'rejected') {
setToastMessage('Committed, but push rejected — remote has new commits. Pull first.')
const remoteStatus = await resolveRemoteStatus()
const nextCommitMode = commitModeFromRemoteStatus(remoteStatus)
const result = await executeCommitAction(vaultPath, message, nextCommitMode)
trackEvent('commit_made')
setToastMessage(commitToastMessage(result))
if (isPushRejected(result)) {
onPushRejected?.()
} else {
setToastMessage(result.message)
}
loadModifiedFiles()
await loadModifiedFiles()
await resolveRemoteStatus()
} catch (err) {
console.error('Commit failed:', err)
setToastMessage(`Commit failed: ${err}`)
setToastMessage(`Commit failed: ${formatCommitError(err)}`)
}
}, [savePending, commitAndPush, loadModifiedFiles, setToastMessage, onPushRejected])
}, [loadModifiedFiles, onPushRejected, resolveRemoteStatus, savePending, setToastMessage, vaultPath])
const closeCommitDialog = useCallback(() => setShowCommitDialog(false), [])
return { showCommitDialog, openCommitDialog, handleCommitPush, closeCommitDialog }
return { showCommitDialog, commitMode, openCommitDialog, handleCommitPush, closeCommitDialog }
}

View File

@@ -0,0 +1,49 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { useGitRemoteStatus } from './useGitRemoteStatus'
const mockInvokeFn = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({
invoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
}))
describe('useGitRemoteStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('loads remote status on mount', async () => {
mockInvokeFn.mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
const { result } = renderHook(() => useGitRemoteStatus('/vault'))
await waitFor(() => {
expect(result.current.remoteStatus).toEqual({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
})
expect(mockInvokeFn).toHaveBeenCalledWith('git_remote_status', { vaultPath: '/vault' })
})
it('refreshRemoteStatus updates the current remote state', async () => {
mockInvokeFn
.mockResolvedValueOnce({ branch: 'main', ahead: 0, behind: 0, hasRemote: true })
.mockResolvedValueOnce({ branch: 'main', ahead: 1, behind: 0, hasRemote: false })
const { result } = renderHook(() => useGitRemoteStatus('/vault'))
await waitFor(() => {
expect(result.current.remoteStatus?.hasRemote).toBe(true)
})
await act(async () => {
await result.current.refreshRemoteStatus()
})
expect(result.current.remoteStatus).toEqual({ branch: 'main', ahead: 1, behind: 0, hasRemote: false })
})
})

View File

@@ -0,0 +1,52 @@
import { useCallback, useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { GitRemoteStatus } from '../types'
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
export interface GitRemoteState {
remoteStatus: GitRemoteStatus | null
refreshRemoteStatus: () => Promise<GitRemoteStatus | null>
}
async function readRemoteStatus(vaultPath: string): Promise<GitRemoteStatus> {
return tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
}
export function useGitRemoteStatus(vaultPath: string): GitRemoteState {
const [remoteStatus, setRemoteStatus] = useState<GitRemoteStatus | null>(null)
const refreshRemoteStatus = useCallback(async () => {
try {
const status = await readRemoteStatus(vaultPath)
setRemoteStatus(status)
return status
} catch {
setRemoteStatus(null)
return null
}
}, [vaultPath])
useEffect(() => {
let cancelled = false
async function loadRemoteStatus() {
try {
const status = await readRemoteStatus(vaultPath)
if (!cancelled) setRemoteStatus(status)
} catch {
if (!cancelled) setRemoteStatus(null)
}
}
void loadRemoteStatus()
return () => {
cancelled = true
}
}, [vaultPath])
return { remoteStatus, refreshRemoteStatus }
}

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,74 @@ 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 {
defaultVaults: VaultOption[]
extraVaults: VaultOption[]
hiddenDefaults: string[]
onSwitchRef: MutableRefObject<() => void>
onToastRef: MutableRefObject<(msg: string) => void>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setVaultPath: Dispatch<SetStateAction<string>>
}
function labelFromPath(path: string): string {
return path.split('/').pop() || 'Local Vault'
}
@@ -33,153 +102,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 | null,
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

@@ -7,6 +7,7 @@ const FIXTURE_VAULT = path.resolve('tests/fixtures/test-vault')
const FIXTURE_VAULT_READY_TIMEOUT = 30_000
const FIXTURE_VAULT_REMOVE_RETRIES = 10
const FIXTURE_VAULT_REMOVE_RETRY_DELAY_MS = 100
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
function copyDirSync(src: string, dest: string): void {
fs.mkdirSync(dest, { recursive: true })
@@ -41,8 +42,9 @@ export async function openFixtureVault(
page: Page,
vaultPath: string,
): Promise<void> {
await page.addInitScript((resolvedVaultPath: string) => {
await page.addInitScript(({ dismissedKey, resolvedVaultPath }: { dismissedKey: string; resolvedVaultPath: string }) => {
localStorage.clear()
localStorage.setItem(dismissedKey, '1')
const nativeFetch = window.fetch.bind(window)
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
@@ -93,7 +95,7 @@ export async function openFixtureVault(
return applyFixtureVaultOverrides(ref) ?? ref
},
})
}, vaultPath)
}, { dismissedKey: CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, resolvedVaultPath: vaultPath })
await page.goto('/', { waitUntil: 'domcontentloaded' })
await page.waitForFunction(() => Boolean(window.__mockHandlers))

View File

@@ -51,5 +51,8 @@ test('Getting Started template shows inline retry on clone failure and opens aft
await page.getByTestId('welcome-retry-template').click()
await expect(page.getByTestId('welcome-screen')).not.toBeVisible()
await expect(page.getByTestId('claude-onboarding-screen')).toBeVisible()
await expect(page.getByText('Claude Code not detected')).toBeVisible()
await page.getByTestId('claude-onboarding-continue').click()
await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible()
})

View File

@@ -0,0 +1,59 @@
import { test, expect } from '@playwright/test'
import { executeCommand, openCommandPalette } from './helpers'
test('commit flow stays local when the active vault has no remote @smoke', async ({ page }) => {
await page.addInitScript(() => {
type Handler = (args?: Record<string, unknown>) => unknown
type BrowserWindow = Window & typeof globalThis & {
__gitPushCalls?: number
__mockHandlers?: Record<string, Handler>
__mockHandlersRef?: Record<string, Handler> | null
}
const browserWindow = window as BrowserWindow
const applyOverrides = (handlers?: Record<string, Handler> | null) => {
if (!handlers) return handlers ?? null
handlers.git_remote_status = () => ({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
handlers.git_push = () => {
browserWindow.__gitPushCalls = (browserWindow.__gitPushCalls ?? 0) + 1
return { status: 'ok', message: 'Pushed to remote' }
}
return handlers
}
browserWindow.__gitPushCalls = 0
let ref = applyOverrides(browserWindow.__mockHandlers) ?? null
Object.defineProperty(browserWindow, '__mockHandlers', {
configurable: true,
set(value) {
ref = applyOverrides(value as Record<string, Handler> | undefined) ?? null
},
get() {
return applyOverrides(ref) ?? ref
},
})
})
await page.goto('/')
await page.waitForLoadState('networkidle')
await expect(page.getByTestId('status-no-remote')).toContainText('No remote')
await openCommandPalette(page)
await executeCommand(page, 'Commit & Push')
await expect(page.getByRole('heading', { name: 'Commit' })).toBeVisible()
await expect(page.getByText(/local commit only/i)).toBeVisible()
await page.locator('textarea[placeholder="Commit message..."]').fill('test local commit')
await page.getByRole('button', { name: 'Commit' }).click()
await expect(page.locator('.fixed.bottom-8')).toContainText('Committed locally', { timeout: 5000 })
await expect.poll(async () =>
page.evaluate(() => (window as Window & { __gitPushCalls?: number }).__gitPushCalls ?? 0),
).toBe(0)
})

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