Merge branch 'main' into pr-735
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
17
src/App.tsx
17
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()
|
||||
|
||||
@@ -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(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
@@ -781,9 +781,10 @@ describe('StatusBar', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<GitRepositorySelect
|
||||
label={translate(locale, 'git.repository.select')}
|
||||
repositories={repositories}
|
||||
selectedPath={selectedRepositoryPath}
|
||||
onChange={onRepositoryChange}
|
||||
testId="git-status-repository-select"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||
<GitBranch size={13} style={{ color: 'var(--muted-foreground)' }} />
|
||||
<span style={{ fontWeight: 500 }}>{remoteStatus?.branch || '—'}</span>
|
||||
</div>
|
||||
<RemoteStatusSummary remoteStatus={remoteStatus} locale={locale} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4, color: 'var(--muted-foreground)' }}>
|
||||
{translate(locale, 'status.sync.status', { status: syncStatusText(locale, status) })}
|
||||
@@ -681,9 +652,6 @@ export function SyncBadge({
|
||||
const [showPopup, setShowPopup] = useState(false)
|
||||
const popupRef = useRef<HTMLDivElement>(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({
|
||||
<StatusBarAction copy={syncBadgeTooltipCopy(locale, status)} onClick={handleClick} testId="status-sync" compact={compact}>
|
||||
<span style={ICON_STYLE}>
|
||||
<SyncStatusIcon status={status} color={syncIconColor(status)} spinning={isSyncing} />
|
||||
{compact ? null : formatSyncBadgeLabel(locale, status, lastSyncTime, selectedRepositoryLabel)}
|
||||
{compact ? null : formatSyncLabel(locale, status, lastSyncTime)}
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
{showPopup && (
|
||||
|
||||
@@ -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<GitCommandsConfig, 'onPull' | 'onPullRepository' | 'repositories'>): 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<GitCommandsConfig, 'onPull'>): 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' }) },
|
||||
]
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
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<void>
|
||||
}
|
||||
|
||||
interface PullOutcome {
|
||||
result: GitPullResult
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
interface PushOutcome {
|
||||
result: GitPushResult
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
interface UpdatedVaultRefreshOptions {
|
||||
outcomes: PullOutcome[]
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>
|
||||
setConflictFiles: SyncSetState<string[]>
|
||||
setConflictVaultPath: SyncSetState<string | null>
|
||||
setSyncStatus: SyncSetState<SyncStatus>
|
||||
}
|
||||
|
||||
interface ConflictStateOptions {
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>
|
||||
files: string[]
|
||||
setConflictFiles: SyncSetState<string[]>
|
||||
setConflictVaultPath: SyncSetState<string | null>
|
||||
setSyncStatus: SyncSetState<SyncStatus>
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
function uniqueVaultPaths(paths: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
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<SyncStatus>,
|
||||
setConflictFiles: SyncSetState<string[]>,
|
||||
setConflictVaultPath: SyncSetState<string | null>,
|
||||
): void {
|
||||
setSyncStatus('idle')
|
||||
setConflictFiles([])
|
||||
setConflictVaultPath(null)
|
||||
}
|
||||
|
||||
function setConflictState(
|
||||
files: string[],
|
||||
setSyncStatus: SyncSetState<SyncStatus>,
|
||||
setConflictFiles: SyncSetState<string[]>,
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>,
|
||||
): 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<GitRemoteStatus | null>,
|
||||
) {
|
||||
return useCallback(async (targetVaultPath = vaultPath) => {
|
||||
try {
|
||||
const status = await tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath: targetVaultPath })
|
||||
setRemoteStatus(status)
|
||||
return status
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [vaultPath, setRemoteStatus])
|
||||
function useRemoteStatusRefresher(setRemoteStatus: SyncSetState<GitRemoteStatus | null>) {
|
||||
return useCallback(async (targetVaultPaths: string[]) => {
|
||||
const statuses = await Promise.all(targetVaultPaths.map(async (targetVaultPath) => {
|
||||
try {
|
||||
return await tauriCall<GitRemoteStatus>('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<SyncStatus>,
|
||||
setConflictFiles: SyncSetState<string[]>,
|
||||
setConflictVaultPath: SyncSetState<string | null>,
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>,
|
||||
) {
|
||||
return useCallback(async (targetVaultPath = vaultPath): Promise<boolean> => {
|
||||
try {
|
||||
const files = await tauriCall<string[]>('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<boolean> => {
|
||||
const conflictChecks = await Promise.all(targetVaultPaths.map(async (targetVaultPath) => {
|
||||
try {
|
||||
return {
|
||||
files: await tauriCall<string[]>('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<SyncCallbacks>
|
||||
setConflictFiles: SyncSetState<string[]>
|
||||
setSyncStatus: SyncSetState<SyncStatus>
|
||||
}): Promise<void> {
|
||||
async function refreshUpdatedVaults(options: UpdatedVaultRefreshOptions): Promise<void> {
|
||||
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<void> {
|
||||
const { outcomes, callbacksRef } = options
|
||||
await refreshUpdatedVaults(options)
|
||||
await callbacksRef.current.onToast(pulledUpdateToast(outcomes))
|
||||
}
|
||||
|
||||
async function resolvePullError(options: PullErrorResolution): Promise<void> {
|
||||
@@ -167,29 +270,35 @@ async function resolvePullError(options: PullErrorResolution): Promise<void> {
|
||||
}
|
||||
|
||||
function handlePushResult(options: {
|
||||
pushResult: GitPushResult
|
||||
outcomes: PushOutcome[]
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>
|
||||
setConflictFiles: SyncSetState<string[]>
|
||||
setConflictVaultPath: SyncSetState<string | null>
|
||||
setSyncStatus: SyncSetState<SyncStatus>
|
||||
}): 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<void> {
|
||||
@@ -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<SyncStatus>('idle')
|
||||
const [lastSyncTime, setLastSyncTime] = useState<number | null>(null)
|
||||
const [conflictFiles, setConflictFiles] = useState<string[]>([])
|
||||
const [conflictVaultPath, setConflictVaultPath] = useState<string | null>(null)
|
||||
const [lastCommitInfo, setLastCommitInfo] = useState<LastCommitInfo | null>(null)
|
||||
const [remoteStatus, setRemoteStatus] = useState<GitRemoteStatus | null>(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<GitPullResult>('git_pull', { vaultPath: targetVaultPath })
|
||||
markPullTimestamp(setLastSyncTime, refreshCommitInfo, targetVaultPath)
|
||||
const outcomes = await Promise.all(pullVaultPaths.map(async (path) => ({
|
||||
result: await tauriCall<GitPullResult>('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<GitPullResult>('git_pull', { vaultPath: targetVaultPath })
|
||||
markPullTimestamp(setLastSyncTime, refreshCommitInfo, targetVaultPath)
|
||||
const pullOutcomes = await Promise.all(pullVaultPaths.map(async (path) => ({
|
||||
result: await tauriCall<GitPullResult>('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<GitPushResult>('git_push', { vaultPath: targetVaultPath })
|
||||
const pushOutcomes = await Promise.all(pullVaultPaths.map(async (path) => ({
|
||||
result: await tauriCall<GitPushResult>('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 }
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user