feat: support non-git vaults
This commit is contained in:
@@ -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`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
39
docs/adr/0085-non-git-vault-support.md
Normal file
39
docs/adr/0085-non-git-vault-support.md
Normal file
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -147,6 +147,8 @@ const mockCommandResults: Record<string, unknown> = {
|
||||
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,
|
||||
|
||||
73
src/App.tsx
73
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<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!resolvedPath) return
|
||||
setGitRepoState('checking')
|
||||
const check = isTauri()
|
||||
? invoke<boolean>('is_git_repo', { vaultPath: resolvedPath })
|
||||
: Promise.resolve(true) // browser mock: assume git
|
||||
: mockInvoke<boolean>('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 (
|
||||
<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 />
|
||||
}
|
||||
|
||||
return (
|
||||
<NoteRetargetingProvider value={noteRetargetingUi.contextValue}>
|
||||
<div className="app-shell">
|
||||
@@ -1553,7 +1571,8 @@ 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={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => 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() }} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => 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() }} />
|
||||
<GitSetupDialog open={!noteWindowParams && showGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} />
|
||||
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
|
||||
@@ -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(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
|
||||
expect(screen.getByText('Git repository required')).toBeInTheDocument()
|
||||
expect(screen.getByText(/track changes/)).toBeInTheDocument()
|
||||
render(<GitSetupDialog open onInitGit={vi.fn()} onDismiss={vi.fn()} />)
|
||||
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(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
|
||||
expect(screen.getByText('Create repository')).toBeInTheDocument()
|
||||
expect(screen.getByText('Choose another vault')).toBeInTheDocument()
|
||||
render(<GitSetupDialog open onInitGit={vi.fn()} onDismiss={vi.fn()} />)
|
||||
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(<GitRequiredModal onCreateRepo={onCreateRepo} onChooseVault={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Create repository'))
|
||||
expect(onCreateRepo).toHaveBeenCalledOnce()
|
||||
it('calls onInitGit when primary button clicked', async () => {
|
||||
const onInitGit = vi.fn().mockResolvedValue(undefined)
|
||||
render(<GitSetupDialog open onInitGit={onInitGit} onDismiss={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Initialize Git'))
|
||||
expect(onInitGit).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('calls onDismiss when secondary button clicked', () => {
|
||||
const onDismiss = vi.fn()
|
||||
render(<GitSetupDialog open onInitGit={vi.fn()} onDismiss={onDismiss} />)
|
||||
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<void>(r => { resolve = r }))
|
||||
render(<GitRequiredModal onCreateRepo={onCreateRepo} onChooseVault={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Create repository'))
|
||||
const onInitGit = vi.fn().mockReturnValue(new Promise<void>(r => { resolve = r }))
|
||||
render(<GitSetupDialog open onInitGit={onInitGit} onDismiss={vi.fn()} />)
|
||||
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(<GitRequiredModal onCreateRepo={onCreateRepo} onChooseVault={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Create repository'))
|
||||
const onInitGit = vi.fn().mockRejectedValue(new Error('Permission denied'))
|
||||
render(<GitSetupDialog open onInitGit={onInitGit} onDismiss={vi.fn()} />)
|
||||
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(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
|
||||
it('closes on Escape without initializing Git', () => {
|
||||
const onDismiss = vi.fn()
|
||||
const onInitGit = vi.fn()
|
||||
render(<GitSetupDialog open onInitGit={onInitGit} onDismiss={onDismiss} />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<void>
|
||||
onChooseVault: () => void
|
||||
interface GitSetupDialogProps {
|
||||
open: boolean
|
||||
onInitGit: () => Promise<void>
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredModalProps) {
|
||||
export function GitSetupDialog({ open, onInitGit, onDismiss }: GitSetupDialogProps) {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<OnboardingShell
|
||||
style={{ background: 'var(--sidebar)' }}
|
||||
contentClassName="w-full max-w-sm"
|
||||
testId="git-required-shell"
|
||||
>
|
||||
<div className="flex 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">
|
||||
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.
|
||||
</p>
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && !creating) onDismiss()
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="mb-1 flex size-9 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<GitBranch size={18} />
|
||||
</div>
|
||||
<DialogTitle>Enable Git for this vault?</DialogTitle>
|
||||
<DialogDescription>
|
||||
You can keep using this vault without Git. History, sync, commits, and change views stay disabled until you initialize Git.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && (
|
||||
<p className="m-0 rounded-md bg-destructive/10 px-3 py-2 text-center text-[12px] text-destructive">
|
||||
<p className="m-0 rounded-md bg-destructive/10 px-3 py-2 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>
|
||||
</OnboardingShell>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onDismiss} disabled={creating}>
|
||||
Not now
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={creating}>
|
||||
{creating ? 'Initializing…' : 'Initialize Git'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -557,11 +557,46 @@ describe('StatusBar', () => {
|
||||
expect(onClickPulse).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables History badge when isGitVault is false', () => {
|
||||
const onClickPulse = vi.fn()
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} isGitVault={false} onClickPulse={onClickPulse} />)
|
||||
fireEvent.click(screen.getByTestId('status-pulse'))
|
||||
expect(onClickPulse).not.toHaveBeenCalled()
|
||||
it('replaces git controls with a missing-Git warning when isGitVault is false', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
modifiedCount={5}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
isGitVault={false}
|
||||
onClickPulse={vi.fn()}
|
||||
onCommitPush={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
isGitVault={false}
|
||||
onInitializeGit={onInitializeGit}
|
||||
/>
|
||||
)
|
||||
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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -557,6 +557,35 @@ export function CommitButton({
|
||||
)
|
||||
}
|
||||
|
||||
export function MissingGitBadge({
|
||||
onClick,
|
||||
showSeparator = true,
|
||||
compact = false,
|
||||
}: {
|
||||
onClick?: () => void
|
||||
showSeparator?: boolean
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<StatusBarSeparator show={showSeparator} />
|
||||
<StatusBarAction
|
||||
copy={{ label: 'Git is disabled for this vault. Initialize Git to enable history, sync, commits, and change views.' }}
|
||||
onClick={onClick}
|
||||
testId="status-missing-git"
|
||||
className="text-[var(--accent-orange)]"
|
||||
compact={compact}
|
||||
>
|
||||
<span style={ICON_STYLE}>
|
||||
<GitBranch size={13} />
|
||||
{compact ? null : 'Git disabled'}
|
||||
<AlertTriangle size={10} style={{ marginLeft: 2 }} />
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PulseBadge({
|
||||
onClick,
|
||||
disabled,
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<OfflineBadge isOffline={isOffline} showSeparator={!compact} compact={compact} />
|
||||
<NoRemoteBadge remoteStatus={visibleRemoteStatus} onAddRemote={onAddRemote} showSeparator={!compact} compact={compact} />
|
||||
<ChangesBadge count={modifiedCount} onClick={onClickPending} showSeparator={!compact} compact={compact} />
|
||||
<CommitButton onClick={onCommitPush} remoteStatus={visibleRemoteStatus} showSeparator={!compact} compact={compact} />
|
||||
<SyncBadge
|
||||
status={syncStatus}
|
||||
lastSyncTime={lastSyncTime}
|
||||
remoteStatus={visibleRemoteStatus}
|
||||
onTriggerSync={onTriggerSync}
|
||||
onPullAndPush={onPullAndPush}
|
||||
onOpenConflictResolver={onOpenConflictResolver}
|
||||
compact={compact}
|
||||
/>
|
||||
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} showSeparator={!compact} compact={compact} />
|
||||
<PulseBadge onClick={onClickPulse} disabled={isGitVault === false} showSeparator={!compact} compact={compact} />
|
||||
{isGitVault ? (
|
||||
<>
|
||||
<NoRemoteBadge remoteStatus={visibleRemoteStatus} onAddRemote={onAddRemote} showSeparator={!compact} compact={compact} />
|
||||
<ChangesBadge count={modifiedCount} onClick={onClickPending} showSeparator={!compact} compact={compact} />
|
||||
<CommitButton onClick={onCommitPush} remoteStatus={visibleRemoteStatus} showSeparator={!compact} compact={compact} />
|
||||
<SyncBadge
|
||||
status={syncStatus}
|
||||
lastSyncTime={lastSyncTime}
|
||||
remoteStatus={visibleRemoteStatus}
|
||||
onTriggerSync={onTriggerSync}
|
||||
onPullAndPush={onPullAndPush}
|
||||
onOpenConflictResolver={onOpenConflictResolver}
|
||||
compact={compact}
|
||||
/>
|
||||
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} showSeparator={!compact} compact={compact} />
|
||||
<PulseBadge onClick={onClickPulse} showSeparator={!compact} compact={compact} />
|
||||
</>
|
||||
) : (
|
||||
<MissingGitBadge onClick={onInitializeGit} showSeparator={!compact} compact={compact} />
|
||||
)}
|
||||
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} showSeparator={!compact} compact={compact} />}
|
||||
<StatusBarAiBadge
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
@@ -291,8 +301,9 @@ export function StatusBarPrimarySection({
|
||||
onClickPending,
|
||||
onClickPulse,
|
||||
onCommitPush,
|
||||
onInitializeGit,
|
||||
isOffline = false,
|
||||
isGitVault = false,
|
||||
isGitVault = true,
|
||||
syncStatus,
|
||||
lastSyncTime,
|
||||
conflictCount,
|
||||
@@ -363,6 +374,7 @@ export function StatusBarPrimarySection({
|
||||
}}
|
||||
onClickPending={onClickPending}
|
||||
onCommitPush={onCommitPush}
|
||||
onInitializeGit={onInitializeGit}
|
||||
syncStatus={syncStatus}
|
||||
lastSyncTime={lastSyncTime}
|
||||
onTriggerSync={onTriggerSync}
|
||||
|
||||
@@ -4,15 +4,41 @@ import type { SidebarSelection } from '../../types'
|
||||
interface GitCommandsConfig {
|
||||
modifiedCount: number
|
||||
canAddRemote: boolean
|
||||
isGitVault?: boolean
|
||||
onAddRemote?: () => 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?.() },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -17,6 +17,7 @@ function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
}
|
||||
|
||||
interface UseAutoSyncOptions {
|
||||
enabled?: boolean
|
||||
vaultPath: string
|
||||
intervalMinutes: number | null
|
||||
onVaultUpdated: (updatedFiles: string[]) => MaybePromise
|
||||
@@ -212,12 +213,14 @@ async function runSyncTask(options: SyncTaskOptions): Promise<void> {
|
||||
}
|
||||
|
||||
function useAutoSyncLifecycle(options: {
|
||||
enabled: boolean
|
||||
checkExistingConflicts: () => Promise<boolean>
|
||||
intervalMinutes: number | null
|
||||
performPull: () => Promise<void>
|
||||
refreshRemoteStatus: () => Promise<GitRemoteStatus | null>
|
||||
}) {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -340,6 +340,8 @@ export const mockHandlers: Record<string, (args: any) => 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 => {
|
||||
|
||||
@@ -14,12 +14,17 @@ type FixtureCommandArgs = Record<string, unknown> | 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<void> {
|
||||
await page.addInitScript(({ dismissedKey, resolvedVaultPath }: { dismissedKey: string; resolvedVaultPath: string }) => {
|
||||
async function installFixtureVaultInitScript({ page, vaultPath, isGitRepo }: FixtureVaultPageArgs): Promise<void> {
|
||||
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<void> {
|
||||
@@ -398,8 +408,9 @@ async function waitForFixtureVaultReady({ page }: FixturePageArgs): Promise<void
|
||||
export async function openFixtureVault(
|
||||
page: Page,
|
||||
vaultPath: string,
|
||||
options: FixtureVaultOptions = {},
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
await openFixtureVault(page, vaultPath)
|
||||
await openFixtureVault(page, vaultPath, options)
|
||||
await installFixtureVaultDesktopBridge({ page })
|
||||
}
|
||||
|
||||
|
||||
32
tests/smoke/non-git-vault-init.spec.ts
Normal file
32
tests/smoke/non-git-vault-init.spec.ts
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user