Compare commits
5 Commits
v0.2026041
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbbd53d204 | ||
|
|
fd9de04275 | ||
|
|
a13e36a504 | ||
|
|
73a63085c7 | ||
|
|
739e1b3c20 |
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
57
src/App.tsx
57
src/App.tsx
@@ -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 (
|
||||
|
||||
56
src/components/ClaudeCodeOnboardingPrompt.test.tsx
Normal file
56
src/components/ClaudeCodeOnboardingPrompt.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
101
src/components/ClaudeCodeOnboardingPrompt.tsx
Normal file
101
src/components/ClaudeCodeOnboardingPrompt.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
22
src/components/ui/textarea.tsx
Normal file
22
src/components/ui/textarea.tsx
Normal 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 }
|
||||
54
src/hooks/useClaudeCodeOnboarding.test.ts
Normal file
54
src/hooks/useClaudeCodeOnboarding.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
33
src/hooks/useClaudeCodeOnboarding.ts
Normal file
33
src/hooks/useClaudeCodeOnboarding.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
49
src/hooks/useGitRemoteStatus.test.ts
Normal file
49
src/hooks/useGitRemoteStatus.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
52
src/hooks/useGitRemoteStatus.ts
Normal file
52
src/hooks/useGitRemoteStatus.ts
Normal 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 }
|
||||
}
|
||||
43
src/hooks/useNetworkStatus.test.ts
Normal file
43
src/hooks/useNetworkStatus.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
28
src/hooks/useNetworkStatus.ts
Normal file
28
src/hooks/useNetworkStatus.ts
Normal 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 }
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
59
tests/smoke/git-no-remote-commit-only.spec.ts
Normal file
59
tests/smoke/git-no-remote-commit-only.spec.ts
Normal 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)
|
||||
})
|
||||
64
tests/smoke/offline-onboarding-status.spec.ts
Normal file
64
tests/smoke/offline-onboarding-status.spec.ts
Normal 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()
|
||||
})
|
||||
Reference in New Issue
Block a user