feat: show blocking modal when vault has no git repo, offer auto-init
When opening a vault without a .git directory, a blocking modal prevents app use until the user either creates a repository (git init + add + commit) or chooses another vault. The check runs via is_git_repo Tauri command; browser mode fails open. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -128,6 +128,20 @@ pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, St
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn is_git_repo(vault_path: String) -> bool {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
std::path::Path::new(vault_path.as_ref()).join(".git").is_dir()
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn init_git_repo(vault_path: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::init_repo(&vault_path)
|
||||
}
|
||||
|
||||
// ── Git commands (mobile stubs) ─────────────────────────────────────────────
|
||||
|
||||
#[cfg(mobile)]
|
||||
@@ -230,3 +244,15 @@ pub async fn git_remote_status(_vault_path: String) -> Result<GitRemoteStatus, S
|
||||
behind: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn is_git_repo(_vault_path: String) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn init_git_repo(_vault_path: String) -> Result<(), String> {
|
||||
Err("Git init is not available on mobile".into())
|
||||
}
|
||||
|
||||
@@ -138,6 +138,8 @@ pub fn run() {
|
||||
commands::get_conflict_mode,
|
||||
commands::git_resolve_conflict,
|
||||
commands::git_commit_conflict_resolution,
|
||||
commands::is_git_repo,
|
||||
commands::init_git_repo,
|
||||
commands::check_claude_cli,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_claude_agent,
|
||||
|
||||
37
src/App.tsx
37
src/App.tsx
@@ -50,12 +50,14 @@ import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { FlatVaultMigrationBanner } from './components/FlatVaultMigrationBanner'
|
||||
import { useFlatVaultMigration } from './hooks/useFlatVaultMigration'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import type { SidebarSelection, InboxPeriod } from './types'
|
||||
import type { NoteListItem } from './utils/ai-context'
|
||||
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
|
||||
import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
import { isNoteWindow, getNoteWindowParams } from './utils/windowMode'
|
||||
import { GitRequiredModal } from './components/GitRequiredModal'
|
||||
import './App.css'
|
||||
|
||||
// Type declarations for mock content storage and test overrides
|
||||
@@ -95,6 +97,24 @@ function App() {
|
||||
|
||||
// When onboarding resolves to a different vault path, update the switcher
|
||||
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
|
||||
// Git repo check: 'checking' | 'required' | 'ready'
|
||||
const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking')
|
||||
useEffect(() => {
|
||||
if (!resolvedPath) return
|
||||
setGitRepoState('checking')
|
||||
const check = isTauri()
|
||||
? invoke<boolean>('is_git_repo', { vaultPath: resolvedPath })
|
||||
: Promise.resolve(true) // browser mock: assume git
|
||||
check
|
||||
.then(isGit => setGitRepoState(isGit ? 'ready' : 'required'))
|
||||
.catch(() => setGitRepoState('ready')) // fail open
|
||||
}, [resolvedPath])
|
||||
|
||||
const handleInitGitRepo = useCallback(async () => {
|
||||
if (isTauri()) await invoke('init_git_repo', { vaultPath: resolvedPath })
|
||||
setGitRepoState('ready')
|
||||
}, [resolvedPath])
|
||||
|
||||
const vault = useVaultLoader(resolvedPath)
|
||||
useVaultConfig(resolvedPath)
|
||||
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
|
||||
@@ -392,6 +412,23 @@ function App() {
|
||||
return <LoadingView />
|
||||
}
|
||||
|
||||
// Show git-required modal when vault has no git repo (skip for note windows)
|
||||
if (!noteWindowParams && gitRepoState === 'required') {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<GitRequiredModal
|
||||
onCreateRepo={handleInitGitRepo}
|
||||
onChooseVault={vaultSwitcher.handleOpenLocalFolder}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show loading spinner while checking git status
|
||||
if (!noteWindowParams && gitRepoState === 'checking' && onboarding.state.status === 'ready') {
|
||||
return <LoadingView />
|
||||
}
|
||||
|
||||
// Show telemetry consent dialog on first launch (skip for note windows)
|
||||
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
|
||||
return (
|
||||
|
||||
51
src/components/GitRequiredModal.test.tsx
Normal file
51
src/components/GitRequiredModal.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { GitRequiredModal } from './GitRequiredModal'
|
||||
|
||||
describe('GitRequiredModal', () => {
|
||||
it('renders title and explanation', () => {
|
||||
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
|
||||
expect(screen.getByText('Git repository required')).toBeInTheDocument()
|
||||
expect(screen.getByText(/track changes/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders both action buttons', () => {
|
||||
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
|
||||
expect(screen.getByText('Create repository')).toBeInTheDocument()
|
||||
expect(screen.getByText('Choose another vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCreateRepo when primary button clicked', async () => {
|
||||
const onCreateRepo = vi.fn().mockResolvedValue(undefined)
|
||||
render(<GitRequiredModal onCreateRepo={onCreateRepo} onChooseVault={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Create repository'))
|
||||
expect(onCreateRepo).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onChooseVault when secondary button clicked', () => {
|
||||
const onChooseVault = vi.fn()
|
||||
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={onChooseVault} />)
|
||||
fireEvent.click(screen.getByText('Choose another vault'))
|
||||
expect(onChooseVault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables buttons and shows spinner while creating', async () => {
|
||||
let resolve: () => void
|
||||
const onCreateRepo = vi.fn().mockReturnValue(new Promise<void>(r => { resolve = r }))
|
||||
render(<GitRequiredModal onCreateRepo={onCreateRepo} onChooseVault={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Create repository'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Creating…')).toBeInTheDocument()
|
||||
})
|
||||
resolve!()
|
||||
})
|
||||
|
||||
it('shows error message when creation fails', async () => {
|
||||
const onCreateRepo = vi.fn().mockRejectedValue(new Error('Permission denied'))
|
||||
render(<GitRequiredModal onCreateRepo={onCreateRepo} onChooseVault={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Create repository'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Permission denied/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
57
src/components/GitRequiredModal.tsx
Normal file
57
src/components/GitRequiredModal.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react'
|
||||
import { GitBranch } from '@phosphor-icons/react'
|
||||
|
||||
interface GitRequiredModalProps {
|
||||
onCreateRepo: () => Promise<void>
|
||||
onChooseVault: () => void
|
||||
}
|
||||
|
||||
export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredModalProps) {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onCreateRepo()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center" style={{ background: 'var(--sidebar)' }}>
|
||||
<div className="flex max-w-sm flex-col items-center gap-5 rounded-xl border border-border bg-background p-8 shadow-lg">
|
||||
<GitBranch size={36} className="text-muted-foreground" />
|
||||
<h2 className="m-0 text-lg font-semibold text-foreground">Git repository required</h2>
|
||||
<p className="m-0 text-center text-[13px] leading-relaxed text-muted-foreground">
|
||||
Laputa uses a git repository to track changes, detect moved files, and keep your vault safe.
|
||||
We'll create a local repo — no remote needed.
|
||||
</p>
|
||||
{error && (
|
||||
<p className="m-0 rounded-md bg-destructive/10 px-3 py-2 text-center text-[12px] text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<button
|
||||
className="w-full cursor-pointer rounded-md bg-primary px-4 py-2 text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? 'Creating…' : 'Create repository'}
|
||||
</button>
|
||||
<button
|
||||
className="w-full cursor-pointer rounded-md border border-border bg-transparent px-4 py-2 text-[13px] font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={onChooseVault}
|
||||
disabled={creating}
|
||||
>
|
||||
Choose another vault
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user