diff --git a/src-tauri/src/commands/git.rs b/src-tauri/src/commands/git.rs index e2481e42..d6260ee1 100644 --- a/src-tauri/src/commands/git.rs +++ b/src-tauri/src/commands/git.rs @@ -128,6 +128,20 @@ pub async fn git_remote_status(vault_path: String) -> Result 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 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()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4e48ba07..d2f253ee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/App.tsx b/src/App.tsx index ab790eb4..80766187 100644 --- a/src/App.tsx +++ b/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('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 } + // Show git-required modal when vault has no git repo (skip for note windows) + if (!noteWindowParams && gitRepoState === 'required') { + return ( +
+ +
+ ) + } + + // Show loading spinner while checking git status + if (!noteWindowParams && gitRepoState === 'checking' && onboarding.state.status === 'ready') { + return + } + // Show telemetry consent dialog on first launch (skip for note windows) if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) { return ( diff --git a/src/components/GitRequiredModal.test.tsx b/src/components/GitRequiredModal.test.tsx new file mode 100644 index 00000000..03c369de --- /dev/null +++ b/src/components/GitRequiredModal.test.tsx @@ -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() + expect(screen.getByText('Git repository required')).toBeInTheDocument() + expect(screen.getByText(/track changes/)).toBeInTheDocument() + }) + + it('renders both action buttons', () => { + render() + 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() + fireEvent.click(screen.getByText('Create repository')) + expect(onCreateRepo).toHaveBeenCalledOnce() + }) + + it('calls onChooseVault when secondary button clicked', () => { + const onChooseVault = vi.fn() + render() + 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(r => { resolve = r })) + render() + 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() + fireEvent.click(screen.getByText('Create repository')) + await waitFor(() => { + expect(screen.getByText(/Permission denied/)).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/GitRequiredModal.tsx b/src/components/GitRequiredModal.tsx new file mode 100644 index 00000000..17f2255d --- /dev/null +++ b/src/components/GitRequiredModal.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react' +import { GitBranch } from '@phosphor-icons/react' + +interface GitRequiredModalProps { + onCreateRepo: () => Promise + onChooseVault: () => void +} + +export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredModalProps) { + const [creating, setCreating] = useState(false) + const [error, setError] = useState(null) + + const handleCreate = async () => { + setCreating(true) + setError(null) + try { + await onCreateRepo() + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setCreating(false) + } + } + + return ( +
+
+ +

Git repository required

+

+ 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. +

+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+
+ ) +}