diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 876ece10..f3a21628 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -506,15 +506,16 @@ interface PulseCommit { ### Auto-Sync -`useAutoSync` hook handles automatic git sync: +`useAutoSync` hook handles automatic git sync across every active Git repository: - Configurable interval (from app settings: `auto_pull_interval_minutes`) -- Pulls on interval, pushes after commits -- Awaits the post-pull vault refresh so toasts land after note-list state is fresh +- Pulls the active repository set concurrently on launch, focus, interval, and manual sync +- Pushes the active repository set during divergence recovery +- Awaits the post-pull vault refreshes so toasts land after note-list state is fresh - Reopens the clean active tab from disk only when the pull changed that active note, so unrelated updates do not remount the editor - Detects merge conflicts → opens `ConflictResolverModal` -- Tracks remote status (branch, ahead/behind via `git_remote_status`) +- Tracks aggregate remote status (ahead/behind via `git_remote_status`) - Handles push rejection (divergence) → sets `pull_required` status -- `pullAndPush()`: pulls then auto-pushes for divergence recovery +- `pullAndPush()`: pulls then auto-pushes each active repository for divergence recovery - `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs) ### External Vault Refresh @@ -547,7 +548,7 @@ External vault mutations are any disk writes Tolaria did not just perform throug - **No remote indicator**: Neutral chip in the bottom bar when `GitRemoteStatus.hasRemote === false` - **Pulse view**: Activity feed when Pulse filter is selected - **Pull command**: Cmd+K → "Pull from Remote", also in Vault menu -- **Git status popup**: Click sync badge → shows branch, ahead/behind, Pull button +- **Git status popup**: Click sync badge → shows aggregate ahead/behind and a Pull button for the active repository set - **Conflict banner**: Inline banner in editor with Keep mine / Keep theirs for conflicted notes ## BlockNote Customization diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index fd5c97b7..3fd28f01 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -85,7 +85,7 @@ Linux AppImage builds still use the user's system `git` and `node`. Before Tolar The `settings.multi_workspace_enabled` flag turns the registered vault list into a unified graph. When enabled, `useVaultLoader` loads every available mounted vault, annotates entries with workspace provenance, and lets note lists, quick open, keyword search, backlinks, and wikilink navigation span those vaults. -The selected/default vault remains the write and repository focus. New notes and Type documents use `defaultWorkspacePath` when it points at an available mounted vault, while Git status, commits, sync, folder tree, repair, and watcher behavior stay scoped to explicit repository roots. Saved Views are listed from every mounted vault with source-vault identity, so duplicate view filenames remain separate and edits persist back to the view's owning vault. +The selected/default vault remains the write target for new notes and Type documents when `defaultWorkspacePath` points at an available mounted vault. Git status, changes, AutoGit checkpointing, and sync operate across the active mounted repository set, while history, diff, repair, and file operations still resolve explicit repository roots from the selected surface or entry provenance. Saved Views are listed from every mounted vault with source-vault identity, so duplicate view filenames remain separate and edits persist back to the view's owning vault. The bottom-left `VaultMenu` exposes quick include/exclude controls and a `Manage vaults` entry. The Vaults settings section owns the full identity controls: display name, short label, read-only alias, accent color, removal, and default destination for new notes. diff --git a/src/App.tsx b/src/App.tsx index 101cb16b..bbd62df1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -699,6 +699,7 @@ function App() { const autoSync = useAutoSync({ enabled: gitFeaturesEnabled && gitRepoState === 'ready', vaultPath: gitSurfaces.syncRepositoryPath, + vaultPaths: activeGitRepositoryPaths, intervalMinutes: settings.auto_pull_interval_minutes, onVaultUpdated: handlePulledVaultUpdate, onConflict: (files) => { @@ -751,7 +752,7 @@ function App() { }) const conflictFlow = useConflictFlow({ - resolvedPath: gitSurfaces.syncRepositoryPath, + resolvedPath: autoSync.conflictVaultPath ?? graphDefaultWorkspacePath, entries: visibleEntries, conflictFiles: autoSync.conflictFiles, pausePull: autoSync.pausePull, resumePull: autoSync.resumePull, @@ -1021,8 +1022,6 @@ function App() { const handleAppContentChange = appSave.handleContentChange const handleAppSave = appSave.handleSave const loadModifiedFiles = refreshGitModifiedFiles - const setSyncRepositoryPath = gitSurfaces.setSyncRepositoryPath - const syncRepositoryPath = gitSurfaces.syncRepositoryPath const triggerSync = autoSync.triggerSync const pullAndPush = autoSync.pullAndPush @@ -1047,15 +1046,15 @@ function App() { }, [gitFeaturesEnabled, openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled]) const handlePullRepository = useCallback((targetVaultPath: string) => { if (!gitFeaturesEnabled) return - setSyncRepositoryPath(targetVaultPath) triggerSync(targetVaultPath) - }, [gitFeaturesEnabled, setSyncRepositoryPath, triggerSync]) + }, [gitFeaturesEnabled, triggerSync]) const handlePullSelectedRepository = useCallback(() => { - handlePullRepository(syncRepositoryPath) - }, [handlePullRepository, syncRepositoryPath]) + if (!gitFeaturesEnabled) return + triggerSync() + }, [gitFeaturesEnabled, triggerSync]) const handlePullAndPushSelectedRepository = useCallback(() => { - pullAndPush(syncRepositoryPath) - }, [pullAndPush, syncRepositoryPath]) + pullAndPush() + }, [pullAndPush]) const handleTrackedContentChange = useCallback((path: string, content: string) => { recordAutoGitActivity() diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index 83ced3ee..9633f849 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -757,12 +757,12 @@ describe('StatusBar', () => { fireEvent.click(screen.getByTestId('status-sync')) expect(screen.getByTestId('status-bar')).toHaveStyle({ zIndex: '30' }) expect(screen.getByTestId('git-status-popup')).toBeInTheDocument() - expect(screen.getByText('main')).toBeInTheDocument() + expect(screen.queryByText('main')).not.toBeInTheDocument() expect(screen.getByText(/2 ahead/)).toBeInTheDocument() expect(screen.getByText(/1 behind/)).toBeInTheDocument() }) - it('shows the selected repository on sync controls when multiple vaults are active', () => { + it('keeps sync controls vault-agnostic when multiple vaults are active', () => { render( { /> ) - expect(screen.getByTestId('status-sync')).toHaveTextContent('Work Vault') + expect(screen.getByTestId('status-sync')).not.toHaveTextContent('Work Vault') + expect(screen.getByTestId('status-sync')).toHaveTextContent('Not synced') fireEvent.click(screen.getByTestId('status-sync')) - expect(screen.getByTestId('git-status-repository-select')).toBeInTheDocument() + expect(screen.queryByTestId('git-status-repository-select')).not.toBeInTheDocument() }) it('shows History badge in status bar', () => { diff --git a/src/components/status-bar/StatusBarBadges.extra.test.tsx b/src/components/status-bar/StatusBarBadges.extra.test.tsx index e590ed67..2d0f845e 100644 --- a/src/components/status-bar/StatusBarBadges.extra.test.tsx +++ b/src/components/status-bar/StatusBarBadges.extra.test.tsx @@ -94,7 +94,7 @@ describe('StatusBarBadges extra coverage', () => { fireEvent.click(screen.getByTestId('status-sync')) - expect(screen.getByTestId('git-status-popup')).toHaveTextContent('main') + expect(screen.getByTestId('git-status-popup')).not.toHaveTextContent('main') expect(screen.getByText('↑ 2 ahead')).toBeInTheDocument() expect(screen.getByText('↓ 1 behind')).toBeInTheDocument() expect(screen.getByText(/Status: Synced/)).toBeInTheDocument() diff --git a/src/components/status-bar/StatusBarBadges.tsx b/src/components/status-bar/StatusBarBadges.tsx index 138ee969..49f74af5 100644 --- a/src/components/status-bar/StatusBarBadges.tsx +++ b/src/components/status-bar/StatusBarBadges.tsx @@ -8,8 +8,7 @@ import type { McpStatus } from '../../hooks/useMcpStatus' import { translate, type AppLocale, type TranslationKey } from '../../lib/i18n' import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../../types' import { openExternalUrl } from '../../utils/url' -import { gitRepositoryLabel, type GitRepositoryOption } from '../../utils/gitRepositories' -import { GitRepositorySelect } from '../GitRepositorySelect' +import type { GitRepositoryOption } from '../../utils/gitRepositories' import { useDismissibleLayer } from './useDismissibleLayer' import { ICON_STYLE, SEP_STYLE } from './styles' @@ -45,16 +44,6 @@ function formatSyncLabel(locale: AppLocale, status: SyncStatus, lastSyncTime: nu return labelKey ? translate(locale, labelKey) : formatElapsedSync(locale, lastSyncTime) } -function formatSyncBadgeLabel( - locale: AppLocale, - status: SyncStatus, - lastSyncTime: number | null, - repositoryLabel?: string | null, -): string { - const label = formatSyncLabel(locale, status, lastSyncTime) - return repositoryLabel ? `${repositoryLabel} · ${label}` : label -} - function syncIconColor(status: SyncStatus): string { return SYNC_COLORS.get(status) ?? 'var(--accent-green)' } @@ -447,9 +436,6 @@ function PullAction({ function GitStatusPopup({ status, remoteStatus, - repositories = [], - selectedRepositoryPath, - onRepositoryChange, locale = 'en', onPull, onClose, @@ -482,21 +468,6 @@ function GitStatusPopup({ color: 'var(--foreground)', }} > - {repositories.length > 1 && selectedRepositoryPath && onRepositoryChange && ( -
- -
- )} -
- - {remoteStatus?.branch || '—'} -
{translate(locale, 'status.sync.status', { status: syncStatusText(locale, status) })} @@ -681,9 +652,6 @@ export function SyncBadge({ const [showPopup, setShowPopup] = useState(false) const popupRef = useRef(null) const isSyncing = status === 'syncing' - const selectedRepositoryLabel = selectedRepositoryPath && repositories - ? gitRepositoryLabel(selectedRepositoryPath, repositories) - : null useDismissibleLayer(showPopup, popupRef, () => setShowPopup(false)) @@ -706,7 +674,7 @@ export function SyncBadge({ - {compact ? null : formatSyncBadgeLabel(locale, status, lastSyncTime, selectedRepositoryLabel)} + {compact ? null : formatSyncLabel(locale, status, lastSyncTime)} {showPopup && ( diff --git a/src/hooks/commands/gitCommands.ts b/src/hooks/commands/gitCommands.ts index 9a523e3d..5ddb3030 100644 --- a/src/hooks/commands/gitCommands.ts +++ b/src/hooks/commands/gitCommands.ts @@ -17,32 +17,9 @@ interface GitCommandsConfig { onSelect: (sel: SidebarSelection) => void } -type PullableRepositoryList = [GitRepositoryOption, GitRepositoryOption, ...GitRepositoryOption[]] - -function hasRepositoryPullTargets( - repositories: GitRepositoryOption[] | undefined, -): repositories is PullableRepositoryList { - if (!repositories) return false - return repositories.length > 1 -} - function buildPullCommands({ onPull, - onPullRepository, - repositories, -}: Pick): CommandAction[] { - if (onPullRepository && hasRepositoryPullTargets(repositories)) { - const pullRepository = onPullRepository - return repositories.map((repository, index) => ({ - id: `git-pull-${index}`, - label: `Pull from Remote: ${repository.label}`, - group: 'Git', - keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote', repository.label, repository.path], - enabled: true, - execute: () => pullRepository(repository.path), - })) - } - +}: Pick): CommandAction[] { return [ { id: 'git-pull', label: 'Pull from Remote', group: 'Git', keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote'], enabled: true, execute: () => onPull?.() }, ] @@ -54,12 +31,10 @@ export function buildGitCommands(config: GitCommandsConfig): CommandAction[] { canAddRemote, gitFeaturesEnabled = true, isGitVault = true, - repositories, onAddRemote, onCommitPush, onInitializeGit, onPull, - onPullRepository, onResolveConflicts, onSelect, } = config @@ -82,7 +57,7 @@ export function buildGitCommands(config: GitCommandsConfig): CommandAction[] { 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?.() }, - ...buildPullCommands({ onPull, onPullRepository, repositories }), + ...buildPullCommands({ onPull }), { id: 'resolve-conflicts', label: 'Resolve Conflicts', group: 'Git', keywords: ['conflict', 'merge', 'git', 'sync'], enabled: true, execute: () => onResolveConflicts?.() }, { id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) }, ] diff --git a/src/hooks/useAutoSync.test.ts b/src/hooks/useAutoSync.test.ts index 807c239b..fea1f079 100644 --- a/src/hooks/useAutoSync.test.ts +++ b/src/hooks/useAutoSync.test.ts @@ -36,15 +36,17 @@ describe('useAutoSync', () => { mockInvokeFn.mockImplementation((cmd: string) => { if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO) if (cmd === 'get_conflict_files') return Promise.resolve([]) + if (cmd === 'git_remote_status') return Promise.resolve({ branch: 'main', ahead: 0, behind: 0, hasRemote: true }) return Promise.resolve(upToDate()) }) }) - function renderSync(intervalMinutes: number | null = 5, enabled = true) { + function renderSync(intervalMinutes: number | null = 5, enabled = true, vaultPaths?: string[]) { return renderHook(() => useAutoSync({ enabled, vaultPath: '/Users/luca/Laputa', + vaultPaths, intervalMinutes, onVaultUpdated, onConflict, @@ -60,6 +62,15 @@ describe('useAutoSync', () => { }) }) + it('pulls all active vaults on mount', async () => { + renderSync(5, true, ['/Users/luca/Laputa', '/Users/luca/Work']) + + await waitFor(() => { + expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' }) + expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Work' }) + }) + }) + it('does not call git operations when disabled', async () => { const { result } = renderSync(5, false) @@ -482,6 +493,62 @@ describe('useAutoSync', () => { }) }) + it('manual triggerSync syncs every active vault by default', async () => { + const { result } = renderSync(5, true, ['/Users/luca/Laputa', '/Users/luca/Work']) + await waitFor(() => { + expect(result.current.syncStatus).toBe('idle') + }) + + mockInvokeFn.mockClear() + mockInvokeFn.mockImplementation((cmd: string, args: { vaultPath?: string } = {}) => { + if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO) + if (cmd === 'git_remote_status') return Promise.resolve({ branch: 'main', ahead: 0, behind: 0, hasRemote: true }) + if (cmd === 'git_pull') return Promise.resolve(updated([args.vaultPath?.endsWith('/Work') ? 'work.md' : 'home.md'])) + return Promise.resolve(upToDate()) + }) + + await act(async () => { + result.current.triggerSync() + }) + + await waitFor(() => { + expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' }) + expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Work' }) + expect(onVaultUpdated).toHaveBeenCalledWith(['home.md'], '/Users/luca/Laputa') + expect(onVaultUpdated).toHaveBeenCalledWith(['work.md'], '/Users/luca/Work') + expect(onToast).toHaveBeenCalledWith('Pulled 2 update(s) from remote') + }) + }) + + it('pullAndPush pulls and pushes every active vault by default', async () => { + const { result } = renderSync(5, true, ['/Users/luca/Laputa', '/Users/luca/Work']) + await waitFor(() => { + expect(result.current.syncStatus).toBe('idle') + }) + + mockInvokeFn.mockClear() + mockInvokeFn.mockImplementation((cmd: string) => { + if (cmd === 'get_conflict_files') return Promise.resolve([]) + if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO) + if (cmd === 'git_remote_status') return Promise.resolve({ branch: 'main', ahead: 0, behind: 0, hasRemote: true }) + if (cmd === 'git_pull') return Promise.resolve(upToDate()) + if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed successfully' }) + return Promise.resolve(upToDate()) + }) + + await act(async () => { + result.current.pullAndPush() + }) + + await waitFor(() => { + expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' }) + expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Work' }) + expect(mockInvokeFn).toHaveBeenCalledWith('git_push', { vaultPath: '/Users/luca/Laputa' }) + expect(mockInvokeFn).toHaveBeenCalledWith('git_push', { vaultPath: '/Users/luca/Work' }) + expect(onToast).toHaveBeenCalledWith('Pulled and pushed successfully') + }) + }) + it('surfaces pull conflicts and pull errors during recovery pushes', async () => { let mode: 'conflict' | 'error' = 'conflict' diff --git a/src/hooks/useAutoSync.ts b/src/hooks/useAutoSync.ts index deb623e0..0eca2b6e 100644 --- a/src/hooks/useAutoSync.ts +++ b/src/hooks/useAutoSync.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' @@ -19,6 +19,7 @@ function tauriCall(cmd: string, args: Record): Promise { interface UseAutoSyncOptions { enabled?: boolean vaultPath: string + vaultPaths?: string[] intervalMinutes: number | null onVaultUpdated: (updatedFiles: string[], vaultPath: string) => MaybePromise onSyncUpdated?: () => MaybePromise @@ -30,6 +31,7 @@ export interface AutoSyncState { syncStatus: SyncStatus lastSyncTime: number | null conflictFiles: string[] + conflictVaultPath: string | null lastCommitInfo: LastCommitInfo | null remoteStatus: GitRemoteStatus | null triggerSync: (vaultPath?: string) => void @@ -61,22 +63,108 @@ interface SyncTaskOptions { task: () => Promise } +interface PullOutcome { + result: GitPullResult + vaultPath: string +} + +interface PushOutcome { + result: GitPushResult + vaultPath: string +} + +interface UpdatedVaultRefreshOptions { + outcomes: PullOutcome[] + callbacksRef: MutableRefObject + setConflictFiles: SyncSetState + setConflictVaultPath: SyncSetState + setSyncStatus: SyncSetState +} + +interface ConflictStateOptions { + callbacksRef: MutableRefObject + files: string[] + setConflictFiles: SyncSetState + setConflictVaultPath: SyncSetState + setSyncStatus: SyncSetState + vaultPath: string +} + +function uniqueVaultPaths(paths: string[]): string[] { + const seen = new Set() + return paths.filter((path) => { + const trimmed = path.trim() + if (!trimmed || seen.has(trimmed)) return false + seen.add(trimmed) + return true + }) +} + +function aggregateRemoteStatuses(statuses: GitRemoteStatus[]): GitRemoteStatus | null { + if (statuses.length === 0) return null + + return { + branch: statuses.length === 1 ? statuses[0].branch : '', + ahead: statuses.reduce((sum, status) => sum + (status.ahead ?? 0), 0), + behind: statuses.reduce((sum, status) => sum + (status.behind ?? 0), 0), + hasRemote: statuses.some((status) => status.hasRemote === true), + } +} + +function updatedPullOutcomes(outcomes: PullOutcome[]): PullOutcome[] { + return outcomes.filter((outcome) => outcome.result.status === 'updated') +} + +function firstConflictOutcome(outcomes: PullOutcome[]): PullOutcome | null { + return outcomes.find((outcome) => outcome.result.status === 'conflict') ?? null +} + +function hasPullError(outcomes: PullOutcome[]): boolean { + return outcomes.some((outcome) => outcome.result.status === 'error') +} + +function firstPullError(outcomes: PullOutcome[]): PullOutcome | null { + return outcomes.find((outcome) => outcome.result.status === 'error') ?? null +} + +function pulledUpdateToast(outcomes: PullOutcome[]): string { + const updateCount = outcomes.reduce((sum, outcome) => sum + outcome.result.updatedFiles.length, 0) + return `Pulled ${updateCount} update(s) from remote` +} + +function pushSuccessToast(): string { + return 'Pulled and pushed successfully' +} + +function rejectedPushCount(outcomes: PushOutcome[]): number { + return outcomes.filter((outcome) => outcome.result.status === 'rejected').length +} + +function firstFailedPush(outcomes: PushOutcome[]): PushOutcome | null { + return outcomes.find((outcome) => outcome.result.status !== 'ok' && outcome.result.status !== 'rejected') ?? null +} + function clearConflictState( setSyncStatus: SyncSetState, setConflictFiles: SyncSetState, + setConflictVaultPath: SyncSetState, ): void { setSyncStatus('idle') setConflictFiles([]) + setConflictVaultPath(null) } -function setConflictState( - files: string[], - setSyncStatus: SyncSetState, - setConflictFiles: SyncSetState, - callbacksRef: MutableRefObject, -): void { +function setConflictState({ + callbacksRef, + files, + setConflictFiles, + setConflictVaultPath, + setSyncStatus, + vaultPath, +}: ConflictStateOptions): void { setSyncStatus('conflict') setConflictFiles(files) + setConflictVaultPath(vaultPath) void callbacksRef.current.onConflict(files) } @@ -89,37 +177,51 @@ function markPullTimestamp( refreshCommitInfo(vaultPath) } -function useRemoteStatusRefresher( - vaultPath: string, - setRemoteStatus: SyncSetState, -) { - return useCallback(async (targetVaultPath = vaultPath) => { - try { - const status = await tauriCall('git_remote_status', { vaultPath: targetVaultPath }) - setRemoteStatus(status) - return status - } catch { - return null - } - }, [vaultPath, setRemoteStatus]) +function useRemoteStatusRefresher(setRemoteStatus: SyncSetState) { + return useCallback(async (targetVaultPaths: string[]) => { + const statuses = await Promise.all(targetVaultPaths.map(async (targetVaultPath) => { + try { + return await tauriCall('git_remote_status', { vaultPath: targetVaultPath }) + } catch { + return null + } + })) + const aggregate = aggregateRemoteStatuses(statuses.filter((status): status is GitRemoteStatus => status !== null)) + setRemoteStatus(aggregate) + return aggregate + }, [setRemoteStatus]) } function useConflictChecker( - vaultPath: string, setSyncStatus: SyncSetState, setConflictFiles: SyncSetState, + setConflictVaultPath: SyncSetState, callbacksRef: MutableRefObject, ) { - return useCallback(async (targetVaultPath = vaultPath): Promise => { - try { - const files = await tauriCall('get_conflict_files', { vaultPath: targetVaultPath }) - if (!Array.isArray(files) || files.length === 0) return false - setConflictState(files, setSyncStatus, setConflictFiles, callbacksRef) - return true - } catch { - return false - } - }, [vaultPath, setSyncStatus, setConflictFiles, callbacksRef]) + return useCallback(async (targetVaultPaths: string[]): Promise => { + const conflictChecks = await Promise.all(targetVaultPaths.map(async (targetVaultPath) => { + try { + return { + files: await tauriCall('get_conflict_files', { vaultPath: targetVaultPath }), + vaultPath: targetVaultPath, + } + } catch { + return { files: [], vaultPath: targetVaultPath } + } + })) + const conflict = conflictChecks.find(({ files }) => Array.isArray(files) && files.length > 0) + if (!conflict) return false + + setConflictState({ + callbacksRef, + files: conflict.files, + setSyncStatus, + setConflictFiles, + setConflictVaultPath, + vaultPath: conflict.vaultPath, + }) + return true + }, [setSyncStatus, setConflictFiles, setConflictVaultPath, callbacksRef]) } function useCommitInfoRefresher( @@ -133,24 +235,25 @@ function useCommitInfoRefresher( }, [vaultPath, setLastCommitInfo]) } -async function handleUpdatedPull(options: { - result: GitPullResult - vaultPath: string - callbacksRef: MutableRefObject - setConflictFiles: SyncSetState - setSyncStatus: SyncSetState -}): Promise { +async function refreshUpdatedVaults(options: UpdatedVaultRefreshOptions): Promise { const { - result, - vaultPath, + outcomes, callbacksRef, setConflictFiles, + setConflictVaultPath, setSyncStatus, } = options - clearConflictState(setSyncStatus, setConflictFiles) - await callbacksRef.current.onVaultUpdated(result.updatedFiles, vaultPath) + clearConflictState(setSyncStatus, setConflictFiles, setConflictVaultPath) + await Promise.all(outcomes.map((outcome) => ( + callbacksRef.current.onVaultUpdated(outcome.result.updatedFiles, outcome.vaultPath) + ))) await callbacksRef.current.onSyncUpdated?.() - await callbacksRef.current.onToast(`Pulled ${result.updatedFiles.length} update(s) from remote`) +} + +async function handleUpdatedPull(options: UpdatedVaultRefreshOptions): Promise { + const { outcomes, callbacksRef } = options + await refreshUpdatedVaults(options) + await callbacksRef.current.onToast(pulledUpdateToast(outcomes)) } async function resolvePullError(options: PullErrorResolution): Promise { @@ -167,29 +270,35 @@ async function resolvePullError(options: PullErrorResolution): Promise { } function handlePushResult(options: { - pushResult: GitPushResult + outcomes: PushOutcome[] callbacksRef: MutableRefObject setConflictFiles: SyncSetState + setConflictVaultPath: SyncSetState setSyncStatus: SyncSetState }): void { const { - pushResult, + outcomes, callbacksRef, setConflictFiles, + setConflictVaultPath, setSyncStatus, } = options - if (pushResult.status === 'ok') { - clearConflictState(setSyncStatus, setConflictFiles) - void callbacksRef.current.onToast('Pulled and pushed successfully') - return - } - if (pushResult.status === 'rejected') { + const rejectedCount = rejectedPushCount(outcomes) + if (rejectedCount > 0) { setSyncStatus('pull_required') void callbacksRef.current.onToast('Push still rejected after pull — try again') return } - setSyncStatus('error') - void callbacksRef.current.onToast(pushResult.message) + + const failedPush = firstFailedPush(outcomes) + if (failedPush) { + setSyncStatus('error') + void callbacksRef.current.onToast(failedPush.result.message) + return + } + + clearConflictState(setSyncStatus, setConflictFiles, setConflictVaultPath) + void callbacksRef.current.onToast(pushSuccessToast()) } async function runSyncTask(options: SyncTaskOptions): Promise { @@ -265,6 +374,7 @@ function useAutoSyncLifecycle(options: { export function useAutoSync({ enabled = true, vaultPath, + vaultPaths, intervalMinutes, onVaultUpdated, onSyncUpdated, @@ -274,6 +384,7 @@ export function useAutoSync({ const [syncStatus, setSyncStatus] = useState('idle') const [lastSyncTime, setLastSyncTime] = useState(null) const [conflictFiles, setConflictFiles] = useState([]) + const [conflictVaultPath, setConflictVaultPath] = useState(null) const [lastCommitInfo, setLastCommitInfo] = useState(null) const [remoteStatus, setRemoteStatus] = useState(null) const syncingRef = useRef(false) @@ -283,12 +394,29 @@ export function useAutoSync({ callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast } }, [onVaultUpdated, onSyncUpdated, onConflict, onToast]) - const refreshRemoteStatus = useRemoteStatusRefresher(vaultPath, setRemoteStatus) - const checkExistingConflicts = useConflictChecker(vaultPath, setSyncStatus, setConflictFiles, callbacksRef) + const targetVaultPaths = useMemo(() => { + const configuredPaths = vaultPaths && vaultPaths.length > 0 ? vaultPaths : [vaultPath] + return uniqueVaultPaths(configuredPaths) + }, [vaultPath, vaultPaths]) + const resolveTargetVaultPaths = useCallback((targetVaultPath?: string) => ( + targetVaultPath ? uniqueVaultPaths([targetVaultPath]) : targetVaultPaths + ), [targetVaultPaths]) + const refreshRemoteStatus = useRemoteStatusRefresher(setRemoteStatus) + const checkExistingConflicts = useConflictChecker(setSyncStatus, setConflictFiles, setConflictVaultPath, callbacksRef) const refreshCommitInfo = useCommitInfoRefresher(vaultPath, setLastCommitInfo) + const checkActiveConflicts = useCallback( + () => checkExistingConflicts(targetVaultPaths), + [checkExistingConflicts, targetVaultPaths], + ) + const refreshActiveRemoteStatus = useCallback( + () => refreshRemoteStatus(targetVaultPaths), + [refreshRemoteStatus, targetVaultPaths], + ) - const performPull = useCallback(async (targetVaultPath = vaultPath) => { + const performPull = useCallback(async (targetVaultPath?: string) => { if (!enabled) return + const pullVaultPaths = resolveTargetVaultPaths(targetVaultPath) + if (pullVaultPaths.length === 0) return await runSyncTask({ blockWhenPaused: true, @@ -297,37 +425,53 @@ export function useAutoSync({ setLastSyncTime, setSyncStatus, task: async () => { - const result = await tauriCall('git_pull', { vaultPath: targetVaultPath }) - markPullTimestamp(setLastSyncTime, refreshCommitInfo, targetVaultPath) + const outcomes = await Promise.all(pullVaultPaths.map(async (path) => ({ + result: await tauriCall('git_pull', { vaultPath: path }), + vaultPath: path, + }))) + markPullTimestamp(setLastSyncTime, refreshCommitInfo, pullVaultPaths[0]) - if (result.status === 'updated') { - await handleUpdatedPull({ - result, - vaultPath: targetVaultPath, + const conflictOutcome = firstConflictOutcome(outcomes) + if (conflictOutcome) { + setConflictState({ callbacksRef, - setConflictFiles, + files: conflictOutcome.result.conflictFiles, setSyncStatus, + setConflictFiles, + setConflictVaultPath, + vaultPath: conflictOutcome.vaultPath, }) - } else if (result.status === 'conflict') { - setConflictState(result.conflictFiles, setSyncStatus, setConflictFiles, callbacksRef) - } else if (result.status === 'error') { + } else if (hasPullError(outcomes)) { await resolvePullError({ - checkExistingConflicts: () => checkExistingConflicts(targetVaultPath), + checkExistingConflicts: () => checkExistingConflicts(pullVaultPaths), callbacksRef, setSyncStatus, }) } else { - clearConflictState(setSyncStatus, setConflictFiles) + const updatedOutcomes = updatedPullOutcomes(outcomes) + if (updatedOutcomes.length > 0) { + await handleUpdatedPull({ + outcomes: updatedOutcomes, + callbacksRef, + setConflictFiles, + setConflictVaultPath, + setSyncStatus, + }) + } else { + clearConflictState(setSyncStatus, setConflictFiles, setConflictVaultPath) + } } - void refreshRemoteStatus(targetVaultPath) + void refreshRemoteStatus(pullVaultPaths) }, }) - }, [enabled, vaultPath, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus]) + }, [enabled, resolveTargetVaultPaths, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus]) /** Pull from remote, then auto-push if successful. Used for divergence recovery. */ - const pullAndPush = useCallback(async (targetVaultPath = vaultPath) => { + const pullAndPush = useCallback(async (targetVaultPath?: string) => { if (!enabled) return + const pullVaultPaths = resolveTargetVaultPaths(targetVaultPath) + if (pullVaultPaths.length === 0) return await runSyncTask({ blockWhenPaused: false, @@ -336,41 +480,63 @@ export function useAutoSync({ setLastSyncTime, setSyncStatus, task: async () => { - const pullResult = await tauriCall('git_pull', { vaultPath: targetVaultPath }) - markPullTimestamp(setLastSyncTime, refreshCommitInfo, targetVaultPath) + const pullOutcomes = await Promise.all(pullVaultPaths.map(async (path) => ({ + result: await tauriCall('git_pull', { vaultPath: path }), + vaultPath: path, + }))) + markPullTimestamp(setLastSyncTime, refreshCommitInfo, pullVaultPaths[0]) - if (pullResult.status === 'conflict') { - setConflictState(pullResult.conflictFiles, setSyncStatus, setConflictFiles, callbacksRef) + const conflictOutcome = firstConflictOutcome(pullOutcomes) + if (conflictOutcome) { + setConflictState({ + callbacksRef, + files: conflictOutcome.result.conflictFiles, + setSyncStatus, + setConflictFiles, + setConflictVaultPath, + vaultPath: conflictOutcome.vaultPath, + }) return } - if (pullResult.status === 'error') { + const pullError = firstPullError(pullOutcomes) + if (pullError) { await resolvePullError({ - checkExistingConflicts: () => checkExistingConflicts(targetVaultPath), - notifyError: `Pull failed: ${pullResult.message}`, + checkExistingConflicts: () => checkExistingConflicts(pullVaultPaths), + notifyError: `Pull failed: ${pullError.result.message}`, callbacksRef, setSyncStatus, }) return } - if (pullResult.status === 'updated') { - await callbacksRef.current.onVaultUpdated(pullResult.updatedFiles, targetVaultPath) - await callbacksRef.current.onSyncUpdated?.() + const updatedOutcomes = updatedPullOutcomes(pullOutcomes) + if (updatedOutcomes.length > 0) { + await refreshUpdatedVaults({ + outcomes: updatedOutcomes, + callbacksRef, + setConflictFiles, + setConflictVaultPath, + setSyncStatus, + }) } - const pushResult = await tauriCall('git_push', { vaultPath: targetVaultPath }) + const pushOutcomes = await Promise.all(pullVaultPaths.map(async (path) => ({ + result: await tauriCall('git_push', { vaultPath: path }), + vaultPath: path, + }))) handlePushResult({ - pushResult, + outcomes: pushOutcomes, callbacksRef, setConflictFiles, + setConflictVaultPath, setSyncStatus, }) - void refreshRemoteStatus(targetVaultPath) + void refreshRemoteStatus(pullVaultPaths) }, }) - }, [enabled, vaultPath, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus]) + }, [enabled, resolveTargetVaultPaths, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus]) const handlePushRejected = useCallback(() => { setSyncStatus('pull_required') @@ -378,21 +544,21 @@ export function useAutoSync({ useAutoSyncLifecycle({ enabled, - checkExistingConflicts, + checkExistingConflicts: checkActiveConflicts, intervalMinutes, performPull, - refreshRemoteStatus, + refreshRemoteStatus: refreshActiveRemoteStatus, }) const pausePull = useCallback(() => { pauseRef.current = true }, []) const resumePull = useCallback(() => { pauseRef.current = false }, []) - const triggerSync = useCallback((targetVaultPath = vaultPath) => { + const triggerSync = useCallback((targetVaultPath?: string) => { if (!enabled) return trackEvent('sync_triggered') void performPull(targetVaultPath) - }, [enabled, performPull, vaultPath]) + }, [enabled, performPull]) - return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync, pullAndPush, pausePull, resumePull, handlePushRejected } + return { syncStatus, lastSyncTime, conflictFiles, conflictVaultPath, lastCommitInfo, remoteStatus, triggerSync, pullAndPush, pausePull, resumePull, handlePushRejected } } diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index 9901e6b1..d1fa3266 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -151,24 +151,27 @@ describe('useCommandRegistry', () => { expect(findCommand(result.current, 'view-changes')).toBeUndefined() }) - it('exposes one pull command per active repository when multiple Git targets are available', () => { + it('exposes a single pull command for all active repositories', () => { + const onPull = vi.fn() const onPullRepository = vi.fn() const config = makeConfig({ gitRepositories: [ { path: '/vault/main', label: 'Main Vault', defaultForNewNotes: true }, { path: '/vault/brian', label: 'Brian', defaultForNewNotes: false }, ], + onPull, onPullRepository, }) const { result } = renderHook(() => useCommandRegistry(config)) - const mainPull = findCommand(result.current, 'git-pull-0') - const brianPull = findCommand(result.current, 'git-pull-1') - expect(mainPull?.label).toBe('Pull from Remote: Main Vault') - expect(brianPull?.label).toBe('Pull from Remote: Brian') + expect(findCommand(result.current, 'git-pull-0')).toBeUndefined() + expect(findCommand(result.current, 'git-pull-1')).toBeUndefined() - brianPull?.execute() - expect(onPullRepository).toHaveBeenCalledWith('/vault/brian') + const pull = findCommand(result.current, 'git-pull') + expect(pull?.label).toBe('Pull from Remote') + pull?.execute() + expect(onPull).toHaveBeenCalled() + expect(onPullRepository).not.toHaveBeenCalled() }) it('resolve-conflicts stays enabled across rerenders', () => {