diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 7120a39a..b4e75c5a 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -57,6 +57,17 @@ The frontmatter parser (Rust: `vault/mod.rs`, TS: `utils/frontmatter.ts`) must f All data lives in markdown files with YAML frontmatter. There is no database — the filesystem is the source of truth. +### Vault Git Capability + +Git is a per-vault capability, not a prerequisite for the document model. A vault can be: + +| State | Meaning | UI behavior | +|---|---|---| +| Git-backed | The vault path contains a Git repository | History, changes, commits, sync, conflict resolution, remotes, AutoGit, and auto-sync are available according to remote/config state | +| Non-git | The vault path is a plain folder | Markdown scanning, editing, search, and navigation work; Git-dependent status-bar controls and command-palette entries are replaced by `Git disabled` + `Initialize Git for Current Vault` | + +Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. Features that depend on Git must check this capability instead of assuming every vault has `.git`. + ### VaultEntry The core data type representing a single note, defined in Rust (`src-tauri/src/vault/mod.rs`) and TypeScript (`src/types.ts`). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3914962c..2748061d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -456,10 +456,12 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri On first launch, `useOnboarding` checks if the default vault exists. If not, it shows `WelcomeScreen` with three options: - **Create a new vault** → creates an empty git repo in a folder the user chooses -- **Open an existing folder** → system file picker +- **Open an existing folder** → system file picker; plain Markdown folders without `.git` open immediately in supported non-git mode - **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately -When an opened folder is not yet a git repo, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures. +When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action. + +When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures. Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch. @@ -583,6 +585,8 @@ flowchart TD `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. +If the current vault is not a Git repository, Tolaria treats Git as disabled instead of degraded. The status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup. Command registration follows the same state: only `Initialize Git for Current Vault` is available in the Git group, while pull, commit, changes, conflict, and remote commands are hidden. `useAutoSync` is disabled for non-git vaults so the app does not run background Git commands against plain folders. + The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds. `useCommitFlow` also exposes `runAutomaticCheckpoint()`, a dialog-free commit path shared by AutoGit and the bottom-bar Commit button. `useAutoGit` watches the last editor activity plus app focus/visibility state, and when the vault is git-backed, all saves are flushed, and no unsaved edits remain, it triggers the same deterministic `Updated N note(s)` / `Updated N file(s)` commit message path after the configured idle or inactive thresholds. The bottom-bar quick action reuses that checkpoint flow after forcing a save first, so manual quick commits and scheduled AutoGit commits stay aligned on message generation and push behavior. diff --git a/docs/adr/0085-non-git-vault-support.md b/docs/adr/0085-non-git-vault-support.md new file mode 100644 index 00000000..ce96fc4a --- /dev/null +++ b/docs/adr/0085-non-git-vault-support.md @@ -0,0 +1,39 @@ +--- +type: ADR +id: "0085" +title: "Non-git vaults open with explicit later Git initialization" +status: active +date: 2026-04-26 +supersedes: "0034" +--- + +## Context + +ADR-0034 made Git a hard prerequisite for opening a vault because Git-backed cache, history, change, and sync flows failed invisibly when users opened plain Markdown folders. That protected Git features, but it blocked the common adoption path of opening an existing folder from Obsidian, iCloud, Dropbox, or a manually maintained notes directory. + +Tolaria now needs the opposite default: browsing and editing Markdown should work immediately, while Git remains an explicit capability users can enable when they want history, sync, commits, or collaboration. + +## Decision + +**Open existing Markdown folders even when they are not Git repositories.** A non-git vault is a supported state, not an error state. On open, Tolaria asks whether to initialize Git; if the user dismisses the prompt, the app keeps working and the status bar permanently shows a `Git disabled` warning. Clicking that warning, or running `Initialize Git for Current Vault` from the command palette, reopens the setup action. + +While a vault is not Git-backed: + +- Git history, change, commit, sync, conflict, and remote actions are hidden or disabled. +- Background auto-sync and AutoGit checkpoints do not run. +- Markdown scanning, note browsing, note editing, search, and non-Git vault features continue normally. + +`init_git_repo` remains the single backend command for enabling Git later. It creates the repository, writes Tolaria's default `.gitignore`, stages the vault, and creates the unsigned setup commit. + +## Options considered + +- **Option A (chosen): Supported non-git mode with explicit later initialization.** Best adoption path; keeps Git capabilities visible without blocking the basic notes workflow. +- **Option B: Keep the ADR-0034 blocking modal.** Prevents Git feature ambiguity, but rejects valid plain-folder workflows. +- **Option C: Auto-initialize Git when opening a plain folder.** Low friction, but surprising for users who do not want Tolaria to mutate folder metadata. + +## Consequences + +- Existing Git-backed vaults keep the same history, commit, sync, and remote behavior. +- UI surfaces must treat Git capability as stateful per vault, not as an app-wide invariant. +- Tests need to cover both Git-backed and non-git vaults in browser mocks and native QA. +- Future Git-dependent features must check the current vault's Git state before registering commands or running background work. diff --git a/docs/adr/README.md b/docs/adr/README.md index c4196022..2facd9d4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -89,7 +89,7 @@ proposed → active → superseded | [0031](0031-full-app-for-note-windows.md) | Full App instance for secondary note windows | active | | [0032](0032-status-bar-for-git-actions.md) | Git actions (Changes, Pulse, Commit) in status bar, not sidebar | active | | [0033](0033-subfolder-scanning-and-folder-tree.md) | Subfolder scanning and folder tree navigation | active | -| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | active | +| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | superseded → [0085](0085-non-git-vault-support.md) | | [0035](0035-path-suffix-wikilink-resolution.md) | Path-suffix wikilink resolution for subfolder vaults | active | | [0036](0036-external-rename-detection-via-git-diff.md) | External rename detection via git diff on focus regain | active | | [0037](0037-codemirror-language-markdown-highlighting.md) | Language-based markdown syntax highlighting in raw editor | active | @@ -139,3 +139,4 @@ proposed → active → superseded | [0081](0081-internal-light-dark-theme-runtime.md) | Internal light and dark theme runtime | active | | [0082](0082-markdown-durable-math-notes.md) | Markdown-durable math in notes | active | | [0083](0083-dual-architecture-macos-release-artifacts.md) | Dual-architecture macOS release artifacts | active | +| [0085](0085-non-git-vault-support.md) | Non-git vaults open with explicit later Git initialization | active | diff --git a/src/App.test.tsx b/src/App.test.tsx index c429cf2c..e25cf04c 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -147,6 +147,8 @@ const mockCommandResults: Record = { sync_vault_asset_scope_for_window: null, get_file_history: [], get_settings: { auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null }, + is_git_repo: true, + init_git_repo: null, git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }, save_settings: null, check_vault_exists: true, @@ -288,6 +290,8 @@ function resetMockCommandResults() { anonymous_id: null, release_channel: null, }, + is_git_repo: true, + init_git_repo: null, save_settings: null, check_vault_exists: true, get_default_vault_path: expectedDefaultVaultPath, diff --git a/src/App.tsx b/src/App.tsx index c0cbca75..e1d2f19d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -84,7 +84,7 @@ import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/ import { openNoteInNewWindow } from './utils/openNoteWindow' import { refreshPulledVaultState } from './utils/pulledVaultRefresh' import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, type NoteWindowParams } from './utils/windowMode' -import { GitRequiredModal } from './components/GitRequiredModal' +import { GitSetupDialog } from './components/GitRequiredModal' import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner' import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents' import type { NoteListMultiSelectionCommands } from './components/note-list/multiSelectionCommands' @@ -319,24 +319,48 @@ function App() { ? onboarding.state.vaultPath : vaultSwitcher.vaultPath ) - // Git repo check: 'checking' | 'required' | 'ready' - const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking') + // Git repo check: 'checking' | 'missing' | 'ready' + const [gitRepoState, setGitRepoState] = useState<'checking' | 'missing' | 'ready'>('checking') + const [showGitSetupDialog, setShowGitSetupDialog] = useState(false) + const dismissedGitSetupPathRef = useRef(null) useEffect(() => { if (!resolvedPath) return setGitRepoState('checking') const check = isTauri() ? invoke('is_git_repo', { vaultPath: resolvedPath }) - : Promise.resolve(true) // browser mock: assume git + : mockInvoke('is_git_repo', { vaultPath: resolvedPath }) check - .then(isGit => setGitRepoState(isGit ? 'ready' : 'required')) + .then(isGit => setGitRepoState(isGit ? 'ready' : 'missing')) .catch(() => setGitRepoState('ready')) // fail open }, [resolvedPath]) - const handleInitGitRepo = useCallback(async () => { - if (isTauri()) await invoke('init_git_repo', { vaultPath: resolvedPath }) - setGitRepoState('ready') + useEffect(() => { + if (noteWindowParams || gitRepoState !== 'missing' || !resolvedPath) return + if (dismissedGitSetupPathRef.current === resolvedPath) return + setShowGitSetupDialog(true) + }, [gitRepoState, noteWindowParams, resolvedPath]) + + const openGitSetupDialog = useCallback(() => { + setShowGitSetupDialog(true) + }, []) + + const dismissGitSetupDialog = useCallback(() => { + dismissedGitSetupPathRef.current = resolvedPath + setShowGitSetupDialog(false) }, [resolvedPath]) + const handleInitGitRepo = useCallback(async () => { + if (isTauri()) { + await invoke('init_git_repo', { vaultPath: resolvedPath }) + } else { + await mockInvoke('init_git_repo', { vaultPath: resolvedPath }) + } + setGitRepoState('ready') + dismissedGitSetupPathRef.current = null + setShowGitSetupDialog(false) + setToastMessage('Git initialized for this vault') + }, [resolvedPath, setToastMessage]) + const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath) const { status: vaultAiGuidanceStatus, @@ -420,6 +444,14 @@ function App() { }, [vault.entries.length, gitRepoState, resolvedPath]) const { mcpStatus, connectMcp, disconnectMcp } = useMcpStatus(resolvedPath, setToastMessage) const gitRemoteStatus = useGitRemoteStatus(resolvedPath) + const loadVaultModifiedFiles = vault.loadModifiedFiles + const refreshGitRemoteStatus = gitRemoteStatus.refreshRemoteStatus + + useEffect(() => { + if (gitRepoState !== 'ready') return + void loadVaultModifiedFiles() + void refreshGitRemoteStatus() + }, [gitRepoState, loadVaultModifiedFiles, refreshGitRemoteStatus]) const openMcpSetupDialog = useCallback(() => { setShowMcpSetupDialog(true) @@ -549,6 +581,7 @@ function App() { vault.unsavedPaths, ]) const autoSync = useAutoSync({ + enabled: gitRepoState === 'ready', vaultPath: resolvedPath, intervalMinutes: settings.auto_pull_interval_minutes, onVaultUpdated: handlePulledVaultUpdate, @@ -858,7 +891,7 @@ function App() { vaultPath: resolvedPath, }) const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles]) - const isGitVault = !vault.modifiedFilesError + const isGitVault = gitRepoState !== 'missing' const modifiedFilesSignature = useMemo( () => vault.modifiedFiles.map((file) => `${file.relativePath}:${file.status}`).sort().join('|'), [vault.modifiedFiles], @@ -1315,6 +1348,8 @@ function App() { onDeleteNote: deleteActions.handleDeleteNote, onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote, onCommitPush: handleCommitPush, + isGitVault, + onInitializeGit: openGitSetupDialog, onPull: autoSync.triggerSync, onResolveConflicts: conflictFlow.handleOpenConflictResolver, onSetViewMode: handleSetViewMode, @@ -1450,23 +1485,6 @@ function App() { ) } - // Show git-required modal when vault has no git repo (skip for note windows) - if (!noteWindowParams && gitRepoState === 'required' && !showMcpSetupDialog) { - return ( -
- -
- ) - } - - // Show loading spinner while checking git status - if (!noteWindowParams && gitRepoState === 'checking' && onboarding.state.status === 'ready') { - return - } - return (
@@ -1553,7 +1571,8 @@ function App() {
- handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} /> + handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} /> + setToastMessage(null)} /> diff --git a/src/components/GitRequiredModal.test.tsx b/src/components/GitRequiredModal.test.tsx index 93e54d95..a8ef42a2 100644 --- a/src/components/GitRequiredModal.test.tsx +++ b/src/components/GitRequiredModal.test.tsx @@ -1,67 +1,62 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' -import { GitRequiredModal } from './GitRequiredModal' +import { GitSetupDialog } from './GitRequiredModal' -const dragRegionMouseDown = vi.fn() - -vi.mock('../hooks/useDragRegion', () => ({ - useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }), -})) - -describe('GitRequiredModal', () => { +describe('GitSetupDialog', () => { it('renders title and explanation', () => { - render() - expect(screen.getByText('Git repository required')).toBeInTheDocument() - expect(screen.getByText(/track changes/)).toBeInTheDocument() + render() + expect(screen.getByText('Enable Git for this vault?')).toBeInTheDocument() + expect(screen.getByText(/You can keep using this vault without Git/)).toBeInTheDocument() }) it('renders both action buttons', () => { - render() - expect(screen.getByText('Create repository')).toBeInTheDocument() - expect(screen.getByText('Choose another vault')).toBeInTheDocument() + render() + expect(screen.getByText('Initialize Git')).toBeInTheDocument() + expect(screen.getByText('Not now')).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 onInitGit when primary button clicked', async () => { + const onInitGit = vi.fn().mockResolvedValue(undefined) + render() + fireEvent.click(screen.getByText('Initialize Git')) + expect(onInitGit).toHaveBeenCalledOnce() }) - it('calls onChooseVault when secondary button clicked', () => { - const onChooseVault = vi.fn() - render() - fireEvent.click(screen.getByText('Choose another vault')) - expect(onChooseVault).toHaveBeenCalledOnce() + it('calls onDismiss when secondary button clicked', () => { + const onDismiss = vi.fn() + render() + fireEvent.click(screen.getByText('Not now')) + expect(onDismiss).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')) + const onInitGit = vi.fn().mockReturnValue(new Promise(r => { resolve = r })) + render() + fireEvent.click(screen.getByText('Initialize Git')) await waitFor(() => { - expect(screen.getByText('Creating…')).toBeInTheDocument() + expect(screen.getByText('Initializing…')).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')) + const onInitGit = vi.fn().mockRejectedValue(new Error('Permission denied')) + render() + fireEvent.click(screen.getByText('Initialize Git')) await waitFor(() => { expect(screen.getByText(/Permission denied/)).toBeInTheDocument() }) }) - it('uses the surrounding surface as a drag region and excludes the card', () => { - render() + it('closes on Escape without initializing Git', () => { + const onDismiss = vi.fn() + const onInitGit = vi.fn() + render() - const shell = screen.getByTestId('git-required-shell') - fireEvent.mouseDown(shell) + fireEvent.keyDown(document, { key: 'Escape' }) - expect(dragRegionMouseDown).toHaveBeenCalledOnce() - expect(shell.querySelector('[data-no-drag]')).not.toBeNull() + expect(onDismiss).toHaveBeenCalledOnce() + expect(onInitGit).not.toHaveBeenCalled() }) }) diff --git a/src/components/GitRequiredModal.tsx b/src/components/GitRequiredModal.tsx index 8d492e1f..32ea7ae6 100644 --- a/src/components/GitRequiredModal.tsx +++ b/src/components/GitRequiredModal.tsx @@ -1,13 +1,22 @@ import { useState } from 'react' -import { GitBranch } from '@phosphor-icons/react' -import { OnboardingShell } from './OnboardingShell' +import { GitBranch } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' -interface GitRequiredModalProps { - onCreateRepo: () => Promise - onChooseVault: () => void +interface GitSetupDialogProps { + open: boolean + onInitGit: () => Promise + onDismiss: () => void } -export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredModalProps) { +export function GitSetupDialog({ open, onInitGit, onDismiss }: GitSetupDialogProps) { const [creating, setCreating] = useState(false) const [error, setError] = useState(null) @@ -15,7 +24,7 @@ export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredMod setCreating(true) setError(null) try { - await onCreateRepo() + await onInitGit() } catch (err) { setError(err instanceof Error ? err.message : String(err)) setCreating(false) @@ -23,40 +32,33 @@ export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredMod } return ( - -
- -

Git repository required

-

- Tolaria uses a git repository to track changes, detect moved files, and keep your vault safe. - We'll create a local repo — no remote needed. -

+ { + if (!nextOpen && !creating) onDismiss() + }}> + + +
+ +
+ Enable Git for this vault? + + You can keep using this vault without Git. History, sync, commits, and change views stay disabled until you initialize Git. + +
{error && ( -

+

{error}

)} -
- - -
-
-
+ + + + + + ) } diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index f4bb021c..ffa94cfb 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -557,11 +557,46 @@ describe('StatusBar', () => { expect(onClickPulse).toHaveBeenCalledOnce() }) - it('disables History badge when isGitVault is false', () => { - const onClickPulse = vi.fn() - render() - fireEvent.click(screen.getByTestId('status-pulse')) - expect(onClickPulse).not.toHaveBeenCalled() + it('replaces git controls with a missing-Git warning when isGitVault is false', () => { + render( + + ) + + expect(screen.getByTestId('status-missing-git')).toBeInTheDocument() + expect(screen.getByText('Git disabled')).toBeInTheDocument() + expect(screen.queryByTestId('status-pulse')).not.toBeInTheDocument() + expect(screen.queryByTestId('status-commit-push')).not.toBeInTheDocument() + }) + + it('opens Git setup from the missing-Git warning with mouse and keyboard', () => { + const onInitializeGit = vi.fn() + render( + + ) + const warning = screen.getByTestId('status-missing-git') + + fireEvent.click(warning) + expect(onInitializeGit).toHaveBeenCalledOnce() + + warning.focus() + fireEvent.keyDown(warning, { key: 'Enter' }) + expect(onInitializeGit).toHaveBeenCalledTimes(2) }) it('shows Commit button in status bar', () => { diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 89d870a8..bc80755e 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -67,6 +67,7 @@ interface StatusBarProps { onClickPending?: () => void onClickPulse?: () => void onCommitPush?: () => void + onInitializeGit?: () => void isOffline?: boolean isGitVault?: boolean syncStatus?: SyncStatus @@ -114,8 +115,9 @@ function StatusBarFooter({ onClickPending, onClickPulse, onCommitPush, + onInitializeGit, isOffline = false, - isGitVault = false, + isGitVault = true, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, @@ -177,6 +179,7 @@ function StatusBarFooter({ onClickPending={onClickPending} onClickPulse={onClickPulse} onCommitPush={onCommitPush} + onInitializeGit={onInitializeGit} isOffline={isOffline} isGitVault={isGitVault} syncStatus={syncStatus} diff --git a/src/components/status-bar/StatusBarBadges.tsx b/src/components/status-bar/StatusBarBadges.tsx index 154ba39b..48bda887 100644 --- a/src/components/status-bar/StatusBarBadges.tsx +++ b/src/components/status-bar/StatusBarBadges.tsx @@ -557,6 +557,35 @@ export function CommitButton({ ) } +export function MissingGitBadge({ + onClick, + showSeparator = true, + compact = false, +}: { + onClick?: () => void + showSeparator?: boolean + compact?: boolean +}) { + return ( + <> + + + + + {compact ? null : 'Git disabled'} + + + + + ) +} + export function PulseBadge({ onClick, disabled, diff --git a/src/components/status-bar/StatusBarSections.tsx b/src/components/status-bar/StatusBarSections.tsx index 0220bcec..691e87de 100644 --- a/src/components/status-bar/StatusBarSections.tsx +++ b/src/components/status-bar/StatusBarSections.tsx @@ -18,6 +18,7 @@ import { ConflictBadge, ChangesBadge, McpBadge, + MissingGitBadge, NoRemoteBadge, OfflineBadge, PulseBadge, @@ -54,6 +55,7 @@ interface StatusBarPrimarySectionProps { onClickPending?: () => void onClickPulse?: () => void onCommitPush?: () => void + onInitializeGit?: () => void isOffline?: boolean isGitVault?: boolean syncStatus: SyncStatus @@ -169,6 +171,7 @@ function StatusBarPrimaryBadges({ onAddRemote, onClickPending, onCommitPush, + onInitializeGit, syncStatus, lastSyncTime, onTriggerSync, @@ -194,6 +197,7 @@ function StatusBarPrimaryBadges({ onAddRemote: () => void onClickPending?: () => void onCommitPush?: () => void + onInitializeGit?: () => void syncStatus: SyncStatus lastSyncTime: number | null onTriggerSync?: () => void @@ -217,20 +221,26 @@ function StatusBarPrimaryBadges({ return ( <> - - - - - - + {isGitVault ? ( + <> + + + + + + + + ) : ( + + )} {mcpStatus && } void onCommitPush: () => void + onInitializeGit?: () => void onPull?: () => void onResolveConflicts?: () => void onSelect: (sel: SidebarSelection) => void } export function buildGitCommands(config: GitCommandsConfig): CommandAction[] { - const { modifiedCount, canAddRemote, onAddRemote, onCommitPush, onPull, onResolveConflicts, onSelect } = config + const { + modifiedCount, + canAddRemote, + isGitVault = true, + onAddRemote, + onCommitPush, + onInitializeGit, + onPull, + onResolveConflicts, + onSelect, + } = config + + if (!isGitVault) { + return [ + { + id: 'initialize-git', + label: 'Initialize Git for Current Vault', + group: 'Git', + keywords: ['git', 'initialize', 'enable', 'history', 'sync'], + enabled: Boolean(onInitializeGit), + execute: () => onInitializeGit?.(), + }, + ] + } + return [ { id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush }, { id: 'add-remote', label: 'Add Remote to Current Vault', group: 'Git', keywords: ['git', 'remote', 'connect', 'origin', 'no remote'], enabled: canAddRemote && !!onAddRemote, execute: () => onAddRemote?.() }, diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 14bdb7f1..3b7f2dfe 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -60,6 +60,8 @@ interface AppCommandsConfig { onCreateEmptyVault?: () => void onAddRemote?: () => void canAddRemote?: boolean + isGitVault?: boolean + onInitializeGit?: () => void onCreateType?: () => void onToggleAIChat?: () => void onCheckForUpdates?: () => void @@ -152,6 +154,8 @@ type CommandRegistryVaultActions = Pick< | 'onCreateEmptyVault' | 'onAddRemote' | 'canAddRemote' + | 'isGitVault' + | 'onInitializeGit' | 'onCheckForUpdates' | 'onCreateType' | 'locale' @@ -405,6 +409,8 @@ function createCommandRegistryVaultConfig( onCreateEmptyVault: config.onCreateEmptyVault, onAddRemote: config.onAddRemote ?? requestAddRemote, canAddRemote: config.canAddRemote ?? true, + isGitVault: config.isGitVault, + onInitializeGit: config.onInitializeGit, onCheckForUpdates: config.onCheckForUpdates, onCreateType: config.onCreateType, locale: config.locale, diff --git a/src/hooks/useAutoSync.test.ts b/src/hooks/useAutoSync.test.ts index da0707fd..398ade88 100644 --- a/src/hooks/useAutoSync.test.ts +++ b/src/hooks/useAutoSync.test.ts @@ -40,9 +40,10 @@ describe('useAutoSync', () => { }) }) - function renderSync(intervalMinutes: number | null = 5) { + function renderSync(intervalMinutes: number | null = 5, enabled = true) { return renderHook(() => useAutoSync({ + enabled, vaultPath: '/Users/luca/Laputa', intervalMinutes, onVaultUpdated, @@ -59,6 +60,26 @@ describe('useAutoSync', () => { }) }) + it('does not call git operations when disabled', async () => { + const { result } = renderSync(5, false) + + await waitFor(() => { + expect(result.current.syncStatus).toBe('idle') + }) + + expect(mockInvokeFn).not.toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' }) + expect(mockInvokeFn).not.toHaveBeenCalledWith('git_remote_status', { vaultPath: '/Users/luca/Laputa' }) + + act(() => { + result.current.triggerSync() + result.current.pullAndPush() + window.dispatchEvent(new Event('focus')) + }) + + expect(mockInvokeFn).not.toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' }) + expect(mockInvokeFn).not.toHaveBeenCalledWith('git_push', { vaultPath: '/Users/luca/Laputa' }) + }) + it('sets syncStatus to idle after up_to_date pull', async () => { const { result } = renderSync() await waitFor(() => { diff --git a/src/hooks/useAutoSync.ts b/src/hooks/useAutoSync.ts index 0896ef23..a5257132 100644 --- a/src/hooks/useAutoSync.ts +++ b/src/hooks/useAutoSync.ts @@ -17,6 +17,7 @@ function tauriCall(cmd: string, args: Record): Promise { } interface UseAutoSyncOptions { + enabled?: boolean vaultPath: string intervalMinutes: number | null onVaultUpdated: (updatedFiles: string[]) => MaybePromise @@ -212,12 +213,14 @@ async function runSyncTask(options: SyncTaskOptions): Promise { } function useAutoSyncLifecycle(options: { + enabled: boolean checkExistingConflicts: () => Promise intervalMinutes: number | null performPull: () => Promise refreshRemoteStatus: () => Promise }) { const { + enabled, checkExistingConflicts, intervalMinutes, performPull, @@ -225,14 +228,18 @@ function useAutoSyncLifecycle(options: { } = options useEffect(() => { + if (!enabled) return + void checkExistingConflicts().then(hasConflicts => { if (!hasConflicts) void performPull() }) void refreshRemoteStatus() - }, [checkExistingConflicts, performPull, refreshRemoteStatus]) + }, [checkExistingConflicts, enabled, performPull, refreshRemoteStatus]) const lastPullTimeRef = useRef(0) useEffect(() => { + if (!enabled) return + const handleFocus = () => { const now = Date.now() if (now - lastPullTimeRef.current < FOCUS_COOLDOWN_MS) return @@ -241,16 +248,19 @@ function useAutoSyncLifecycle(options: { } window.addEventListener('focus', handleFocus) return () => window.removeEventListener('focus', handleFocus) - }, [performPull]) + }, [enabled, performPull]) useEffect(() => { + if (!enabled) return + const ms = (intervalMinutes ?? 5) * 60_000 || DEFAULT_INTERVAL_MS const id = setInterval(() => { void performPull() }, ms) return () => clearInterval(id) - }, [performPull, intervalMinutes]) + }, [enabled, performPull, intervalMinutes]) } export function useAutoSync({ + enabled = true, vaultPath, intervalMinutes, onVaultUpdated, @@ -269,11 +279,14 @@ export function useAutoSync({ useEffect(() => { callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast } }, [onVaultUpdated, onSyncUpdated, onConflict, onToast]) + const refreshRemoteStatus = useRemoteStatusRefresher(vaultPath, setRemoteStatus) const checkExistingConflicts = useConflictChecker(vaultPath, setSyncStatus, setConflictFiles, callbacksRef) const refreshCommitInfo = useCommitInfoRefresher(vaultPath, setLastCommitInfo) const performPull = useCallback(async () => { + if (!enabled) return + await runSyncTask({ blockWhenPaused: true, pauseRef, @@ -306,10 +319,12 @@ export function useAutoSync({ void refreshRemoteStatus() }, }) - }, [vaultPath, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus]) + }, [enabled, vaultPath, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus]) /** Pull from remote, then auto-push if successful. Used for divergence recovery. */ const pullAndPush = useCallback(async () => { + if (!enabled) return + await runSyncTask({ blockWhenPaused: false, pauseRef, @@ -351,13 +366,14 @@ export function useAutoSync({ void refreshRemoteStatus() }, }) - }, [vaultPath, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus]) + }, [enabled, vaultPath, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus]) const handlePushRejected = useCallback(() => { setSyncStatus('pull_required') }, []) useAutoSyncLifecycle({ + enabled, checkExistingConflicts, intervalMinutes, performPull, @@ -368,9 +384,11 @@ export function useAutoSync({ const resumePull = useCallback(() => { pauseRef.current = false }, []) const triggerSync = useCallback(() => { + if (!enabled) return + trackEvent('sync_triggered') void performPull() - }, [performPull]) + }, [enabled, performPull]) return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync, pullAndPush, pausePull, resumePull, handlePushRejected } } diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index 9e234510..8f6489f5 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -98,6 +98,31 @@ describe('useCommandRegistry', () => { expect(cmd!.enabled).toBe(false) }) + it('includes initialize-git command for non-git vaults', () => { + const onInitializeGit = vi.fn() + const config = makeConfig({ isGitVault: false, onInitializeGit }) + const { result } = renderHook(() => useCommandRegistry(config)) + const cmd = findCommand(result.current, 'initialize-git') + + expect(cmd).toBeDefined() + expect(cmd!.group).toBe('Git') + expect(cmd!.label).toBe('Initialize Git for Current Vault') + expect(cmd!.enabled).toBe(true) + + cmd!.execute() + expect(onInitializeGit).toHaveBeenCalledOnce() + }) + + it('hides remote git commands for non-git vaults', () => { + const config = makeConfig({ isGitVault: false, modifiedCount: 5 }) + const { result } = renderHook(() => useCommandRegistry(config)) + + expect(findCommand(result.current, 'commit-push')).toBeUndefined() + expect(findCommand(result.current, 'git-pull')).toBeUndefined() + expect(findCommand(result.current, 'add-remote')).toBeUndefined() + expect(findCommand(result.current, 'view-changes')).toBeUndefined() + }) + it('resolve-conflicts stays enabled across rerenders', () => { const config = makeConfig() const { result, rerender } = renderHook( diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 0df3af42..d63adb28 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -66,6 +66,8 @@ interface CommandRegistryConfig { onCreateEmptyVault?: () => void onAddRemote?: () => void canAddRemote?: boolean + isGitVault?: boolean + onInitializeGit?: () => void onCreateType?: () => void onDeleteNote: (path: string) => void onArchiveNote: (path: string) => void @@ -125,6 +127,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onCustomizeNoteListColumns, canCustomizeNoteListColumns, onRestoreDeletedNote, canRestoreDeletedNote, selection, noteListFilter, onSetNoteListFilter, + isGitVault, onInitializeGit, } = config const hasActiveNote = activeTabPath !== null @@ -168,9 +171,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com }), ...buildGitCommands({ modifiedCount, + isGitVault, canAddRemote: config.canAddRemote ?? false, onAddRemote: config.onAddRemote, onCommitPush, + onInitializeGit, onPull, onResolveConflicts, onSelect, @@ -202,7 +207,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com hasActiveNote, activeTabPath, isArchived, modifiedCount, activeNoteModified, onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings, onOpenFeedback, onDeleteNote, onArchiveNote, onUnarchiveNote, - onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat, onOpenVault, onCreateEmptyVault, config.canAddRemote, config.onAddRemote, + onCommitPush, onInitializeGit, isGitVault, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat, onOpenVault, onCreateEmptyVault, config.canAddRemote, config.onAddRemote, onCheckForUpdates, onZoomIn, onZoomOut, onZoomReset, zoomLevel, onSelect, onRenameFolder, onDeleteFolder, diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index a7fda3d2..1a24c82f 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -340,6 +340,8 @@ export const mockHandlers: Record any> = { }, get_build_number: () => 'bDEV', get_last_commit_info: (): LastCommitInfo => ({ shortHash: 'a1b2c3d', commitUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }), + is_git_repo: () => true, + init_git_repo: () => null, git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }), git_push: (): GitPushResult => ({ status: 'ok', message: 'Pushed to remote' }), git_remote_status: (args?: { vaultPath?: string; vault_path?: string }): GitRemoteStatus => { diff --git a/tests/helpers/fixtureVault.ts b/tests/helpers/fixtureVault.ts index a93ee04d..d8f7657b 100644 --- a/tests/helpers/fixtureVault.ts +++ b/tests/helpers/fixtureVault.ts @@ -14,12 +14,17 @@ type FixtureCommandArgs = Record | undefined interface FixtureVaultPageArgs { page: Page vaultPath: string + isGitRepo: boolean } interface FixturePageArgs { page: Page } +interface FixtureVaultOptions { + isGitRepo?: boolean +} + interface CopyDirArgs { src: string dest: string @@ -62,10 +67,11 @@ export function removeFixtureVaultCopy(tempVaultDir: string | null | undefined): removeFixtureVaultDirectory({ tempVaultDir }) } -async function installFixtureVaultInitScript({ page, vaultPath }: FixtureVaultPageArgs): Promise { - await page.addInitScript(({ dismissedKey, resolvedVaultPath }: { dismissedKey: string; resolvedVaultPath: string }) => { +async function installFixtureVaultInitScript({ page, vaultPath, isGitRepo }: FixtureVaultPageArgs): Promise { + await page.addInitScript(({ dismissedKey, initialIsGitRepo, resolvedVaultPath }: { dismissedKey: string; initialIsGitRepo: boolean; resolvedVaultPath: string }) => { localStorage.clear() localStorage.setItem(dismissedKey, '1') + let gitRepoReady = initialIsGitRepo const jsonHeaders = { 'Content-Type': 'application/json' } const FRONTMATTER_OPEN = '---\n' @@ -236,7 +242,11 @@ async function installFixtureVaultInitScript({ page, vaultPath }: FixtureVaultPa load_vault_list: () => activeVaultList, check_vault_exists: (commandArgs?: FixtureCommandArgs) => readCommandString(commandArgs, 'path') === resolvedVaultPath, - is_git_repo: () => true, + is_git_repo: () => gitRepoReady, + init_git_repo: () => { + gitRepoReady = true + return null + }, get_last_vault_path: () => resolvedVaultPath, get_default_vault_path: () => resolvedVaultPath, save_vault_list: () => null, @@ -383,7 +393,7 @@ async function installFixtureVaultInitScript({ page, vaultPath }: FixtureVaultPa return applyFixtureVaultOverrides(ref) ?? ref }, }) - }, { dismissedKey: CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, resolvedVaultPath: vaultPath }) + }, { dismissedKey: CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, initialIsGitRepo: isGitRepo, resolvedVaultPath: vaultPath }) } async function waitForFixtureVaultReady({ page }: FixturePageArgs): Promise { @@ -398,8 +408,9 @@ async function waitForFixtureVaultReady({ page }: FixturePageArgs): Promise { - await installFixtureVaultInitScript({ page, vaultPath }) + await installFixtureVaultInitScript({ page, vaultPath, isGitRepo: options.isGitRepo ?? true }) await waitForFixtureVaultReady({ page }) } @@ -419,8 +430,9 @@ async function installFixtureVaultDesktopBridge({ page }: FixturePageArgs): Prom export async function openFixtureVaultDesktopHarness( page: Page, vaultPath: string, + options: FixtureVaultOptions = {}, ): Promise { - await openFixtureVault(page, vaultPath) + await openFixtureVault(page, vaultPath, options) await installFixtureVaultDesktopBridge({ page }) } diff --git a/tests/smoke/non-git-vault-init.spec.ts b/tests/smoke/non-git-vault-init.spec.ts new file mode 100644 index 00000000..90826b53 --- /dev/null +++ b/tests/smoke/non-git-vault-init.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test' +import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault' +import { executeCommand, openCommandPalette } from './helpers' + +test('opens a non-git vault and initializes Git later from the keyboard @smoke', async ({ page }) => { + const tempVaultDir = createFixtureVaultCopy() + + try { + await openFixtureVault(page, tempVaultDir, { isGitRepo: false }) + + await expect(page.getByTestId('note-list-container')).toBeVisible() + await expect(page.getByText('Alpha Project', { exact: true }).first()).toBeVisible() + await expect(page.getByRole('heading', { name: 'Enable Git for this vault?' })).toBeVisible() + + await page.keyboard.press('Escape') + await expect(page.getByRole('heading', { name: 'Enable Git for this vault?' })).not.toBeVisible() + await expect(page.getByTestId('status-missing-git')).toContainText('Git disabled') + + await openCommandPalette(page) + await executeCommand(page, 'Initialize Git') + + await expect(page.getByRole('heading', { name: 'Enable Git for this vault?' })).toBeVisible() + await page.getByRole('button', { name: 'Initialize Git' }).focus() + await page.keyboard.press('Enter') + + await expect(page.getByRole('heading', { name: 'Enable Git for this vault?' })).not.toBeVisible() + await expect(page.getByTestId('status-missing-git')).not.toBeVisible() + await expect(page.getByTestId('status-pulse')).toBeVisible() + } finally { + removeFixtureVaultCopy(tempVaultDir) + } +})