Merge branch 'main' into pr-728

This commit is contained in:
github-actions[bot]
2026-05-23 12:57:07 +00:00
committed by GitHub
38 changed files with 646 additions and 100 deletions

View File

@@ -59,6 +59,7 @@ files:
command.git.commitPush: 8cb5faa4019716f43dd69c41496b1d46
command.git.addRemote: 7eef951b3f909340a68dc9811be83e9e
command.git.pull: 3988d61f4a4546381f61a4eaf61315b4
command.git.pullRepository: 931ad25176586fd4332dd671574d3185
command.git.resolveConflicts: 871ac36968cfca3d2297a89b28b25dee
command.git.viewChanges: b2699edf69d8e1031afce2b2f94e5c8d
git.repository.select: 33fcf2b3ec4686d9cd06051c726d0ba2

View File

@@ -636,8 +636,9 @@ function App() {
})
const handleVaultUpdate = useCallback(async (
updatedFiles: string[],
options: { preserveFocusedEditor?: boolean } = {},
options: { preserveFocusedEditor?: boolean; vaultPath?: string } = {},
) => {
const updateVaultPath = options.vaultPath ?? resolvedPath
await refreshPulledVaultState({
activeTabPath: notes.activeTabPath,
closeAllTabs,
@@ -651,7 +652,7 @@ function App() {
reloadViews: vault.reloadViews,
replaceActiveTab: handleReplaceActiveTab,
updatedFiles,
vaultPath: resolvedPath,
vaultPath: updateVaultPath,
})
await refreshGitModifiedFiles()
}, [
@@ -667,7 +668,10 @@ function App() {
vault.unsavedPaths,
])
const handlePulledVaultUpdate = useCallback(
(updatedFiles: string[]) => handleVaultUpdate(updatedFiles, getPulledVaultUpdateOptions()),
(updatedFiles: string[], vaultPath: string) => handleVaultUpdate(updatedFiles, {
...getPulledVaultUpdateOptions(),
vaultPath,
}),
[handleVaultUpdate],
)
const handleFocusedVaultUpdate = useCallback(
@@ -694,7 +698,7 @@ function App() {
})
const autoSync = useAutoSync({
enabled: gitFeaturesEnabled && gitRepoState === 'ready',
vaultPath: resolvedPath,
vaultPath: gitSurfaces.syncRepositoryPath,
intervalMinutes: settings.auto_pull_interval_minutes,
onVaultUpdated: handlePulledVaultUpdate,
onConflict: (files) => {
@@ -747,7 +751,8 @@ function App() {
})
const conflictFlow = useConflictFlow({
resolvedPath, entries: visibleEntries,
resolvedPath: gitSurfaces.syncRepositoryPath,
entries: visibleEntries,
conflictFiles: autoSync.conflictFiles,
pausePull: autoSync.pausePull, resumePull: autoSync.resumePull,
triggerSync: autoSync.triggerSync, reloadVault: vault.reloadVault,
@@ -827,11 +832,13 @@ function App() {
onSetFilter: (filterType) => {
handleSetSelection({ kind: 'sectionGroup', type: filterType })
},
onVaultChanged: (path) => { void handlePulledVaultUpdate(path ? [path] : []) },
onVaultChanged: (path) => { void handlePulledVaultUpdate(path ? [path] : [], resolvedPath) },
})
const handleInitializeProperties = useCallback(async (path: string) => {
await initializeNoteProperties(notes.handleUpdateFrontmatter, path)
const handleInitializeProperties = useCallback((path: string) => {
void initializeNoteProperties(notes.handleUpdateFrontmatter, path).catch((err) => {
console.warn('Failed to initialize note properties:', err)
})
}, [notes])
const handleRemoveNoteIcon = useCallback(async (path: string) => {
@@ -1020,6 +1027,10 @@ 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
useEffect(() => {
if (!gitFeaturesEnabled) return
@@ -1040,6 +1051,17 @@ function App() {
runAutomaticCheckpoint,
})
}, [gitFeaturesEnabled, openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled])
const handlePullRepository = useCallback((targetVaultPath: string) => {
if (!gitFeaturesEnabled) return
setSyncRepositoryPath(targetVaultPath)
triggerSync(targetVaultPath)
}, [gitFeaturesEnabled, setSyncRepositoryPath, triggerSync])
const handlePullSelectedRepository = useCallback(() => {
handlePullRepository(syncRepositoryPath)
}, [handlePullRepository, syncRepositoryPath])
const handlePullAndPushSelectedRepository = useCallback(() => {
pullAndPush(syncRepositoryPath)
}, [pullAndPush, syncRepositoryPath])
const handleTrackedContentChange = useCallback((path: string, content: string) => {
recordAutoGitActivity()
@@ -1559,10 +1581,12 @@ function App() {
onDeleteNote: deleteActions.handleDeleteNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: handleCommitPush,
gitRepositories,
gitFeaturesEnabled,
isGitVault,
onInitializeGit: openGitSetupDialog,
onPull: autoSync.triggerSync,
onPull: handlePullSelectedRepository,
onPullRepository: handlePullRepository,
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
onSetViewMode: handleSetViewMode,
onToggleInspector: handleToggleInspector,
@@ -1792,7 +1816,7 @@ function App() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} 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} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} 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} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} />
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} 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} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.syncRepositoryPath} onRepositoryChange={gitSurfaces.setSyncRepositoryPath} onTriggerSync={handlePullSelectedRepository} onPullAndPush={handlePullAndPushSelectedRepository} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} />
<GitSetupDialog open={gitFeaturesEnabled && shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} onNeverForVault={neverForVaultGitSetupDialog} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />

View File

@@ -573,7 +573,7 @@ describe('Editor', () => {
fireEvent.click(screen.getByRole('button', { name: 'Open table of contents' }))
expect(screen.getByTestId('table-of-contents-panel')).toBeInTheDocument()
expect(await screen.findByRole('button', { name: 'Table Heading' })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: 'Table Heading' }, { timeout: 5000 })).toBeInTheDocument()
})
// Regression: editor content did not appear on first load because BlockNote's

View File

@@ -749,6 +749,30 @@ describe('StatusBar', () => {
expect(screen.getByText(/1 behind/)).toBeInTheDocument()
})
it('shows the selected repository on sync controls when multiple vaults are active', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
multiWorkspaceEnabled={true}
onSwitchVault={vi.fn()}
repositories={[
{ path: '/Users/luca/Laputa', label: 'Main Vault', defaultForNewNotes: true },
{ path: '/Users/luca/Work', label: 'Work Vault', defaultForNewNotes: false },
]}
selectedRepositoryPath="/Users/luca/Work"
onRepositoryChange={vi.fn()}
syncStatus="idle"
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: true }}
/>
)
expect(screen.getByTestId('status-sync')).toHaveTextContent('Work Vault')
fireEvent.click(screen.getByTestId('status-sync'))
expect(screen.getByTestId('git-status-repository-select')).toBeInTheDocument()
})
it('shows History badge in status bar', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} isGitVault />)
expect(screen.getByTestId('status-pulse')).toBeInTheDocument()

View File

@@ -8,6 +8,7 @@ import type { ThemeMode } from '../lib/themeMode'
import type { AppLocale } from '../lib/i18n'
import type { GitRemoteStatus, SyncStatus } from '../types'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { GitRepositoryOption } from '../utils/gitRepositories'
import {
StatusBarPrimarySection,
StatusBarSecondarySection,
@@ -84,6 +85,9 @@ interface StatusBarProps {
lastSyncTime?: number | null
conflictCount?: number
remoteStatus?: GitRemoteStatus | null
repositories?: GitRepositoryOption[]
selectedRepositoryPath?: string
onRepositoryChange?: (path: string) => void
onTriggerSync?: () => void
onPullAndPush?: () => void
onOpenConflictResolver?: () => void
@@ -145,6 +149,9 @@ function StatusBarPrimaryFromFooter({
lastSyncTime = null,
conflictCount = 0,
remoteStatus,
repositories,
selectedRepositoryPath,
onRepositoryChange,
onTriggerSync,
onPullAndPush,
onOpenConflictResolver,
@@ -197,6 +204,9 @@ function StatusBarPrimaryFromFooter({
lastSyncTime={lastSyncTime}
conflictCount={conflictCount}
remoteStatus={remoteStatus}
repositories={repositories}
selectedRepositoryPath={selectedRepositoryPath}
onRepositoryChange={onRepositoryChange}
onTriggerSync={onTriggerSync}
onPullAndPush={onPullAndPush}
onOpenConflictResolver={onOpenConflictResolver}

View File

@@ -4,8 +4,50 @@ import { supportsModernRegexFeatures } from '../utils/regexCapabilities'
const LIGHT_CODE_THEME = 'github-light'
const DARK_CODE_THEME = 'github-dark'
const GO_LANGUAGE = { name: 'Go', aliases: ['go', 'golang'] }
const GO_LANGUAGE_REGISTRATION = {
name: 'go',
displayName: 'Go',
scopeName: 'source.go',
aliases: ['golang'],
patterns: [
{ include: '#comments' },
{ include: '#strings' },
{ include: '#keywords' },
{ include: '#numbers' },
],
repository: {
comments: {
patterns: [
{ begin: '/\\*', end: '\\*/', name: 'comment.block.go' },
{ begin: '//', end: '$', name: 'comment.line.double-slash.go' },
],
},
keywords: {
patterns: [
{
match: '\\b(break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go|goto|if|import|interface|map|package|range|return|select|struct|switch|type|var)\\b',
name: 'keyword.control.go',
},
],
},
numbers: {
patterns: [
{ match: '\\b0[xX][0-9a-fA-F_]+\\b|\\b\\d[\\d_]*(\\.\\d[\\d_]*)?\\b', name: 'constant.numeric.go' },
],
},
strings: {
patterns: [
{ begin: '"', end: '"', name: 'string.quoted.double.go' },
{ begin: '`', end: '`', name: 'string.quoted.raw.go' },
],
},
},
}
type TolariaCodeHighlighter = Awaited<ReturnType<NonNullable<typeof codeBlockOptions.createHighlighter>>>
type TolariaLoadLanguage = TolariaCodeHighlighter['loadLanguage']
type TolariaLanguageInput = Parameters<TolariaLoadLanguage>[number]
function currentCodeBlockTheme() {
if (typeof document === 'undefined') return LIGHT_CODE_THEME
@@ -20,11 +62,20 @@ function prioritizeTheme(themes: string[], theme: string) {
return [theme, ...themes.filter((candidate) => candidate !== theme)]
}
function expandGoLanguage(language: TolariaLanguageInput): TolariaLanguageInput[] {
if (typeof language !== 'string') return [language]
const languageName: string = language
return languageName === 'go' || languageName === 'golang'
? [GO_LANGUAGE_REGISTRATION as TolariaLanguageInput]
: [language]
}
async function createTolariaCodeHighlighter(): Promise<TolariaCodeHighlighter> {
const highlighter = await codeBlockOptions.createHighlighter()
return {
...highlighter,
getLoadedThemes: () => prioritizeTheme(highlighter.getLoadedThemes(), currentCodeBlockTheme()),
loadLanguage: (...languages) => highlighter.loadLanguage(...languages.flatMap(expandGoLanguage)),
}
}
@@ -33,6 +84,10 @@ export function createTolariaCodeBlockOptions(): Partial<CodeBlockOptions> {
...codeBlockOptions,
createHighlighter: createTolariaCodeHighlighter,
defaultLanguage: 'text',
supportedLanguages: {
...codeBlockOptions.supportedLanguages,
go: GO_LANGUAGE,
},
}
if (supportsModernRegexFeatures()) return options

View File

@@ -99,12 +99,7 @@ describe('StatusBarBadges extra coverage', () => {
expect(screen.getByText('↓ 1 behind')).toBeInTheDocument()
expect(screen.getByText(/Status: Synced/)).toBeInTheDocument()
const pullButton = screen.getByTestId('git-status-pull-btn')
fireEvent.mouseEnter(pullButton)
expect(pullButton.style.background).toBe('var(--hover)')
fireEvent.mouseLeave(pullButton)
expect(pullButton.style.background).toBe('transparent')
const pullButton = screen.getByRole('button', { name: 'Pull' })
fireEvent.click(pullButton)
expect(onTriggerSync).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('git-status-popup')).not.toBeInTheDocument()

View File

@@ -8,6 +8,8 @@ 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 { useDismissibleLayer } from './useDismissibleLayer'
import { ICON_STYLE, SEP_STYLE } from './styles'
@@ -43,6 +45,16 @@ 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)'
}
@@ -415,29 +427,19 @@ function PullAction({
return (
<div style={{ display: 'flex', gap: 4, marginTop: 6, borderTop: '1px solid var(--border)', paddingTop: 6 }}>
<button
<Button
type="button"
variant="outline"
size="xs"
onClick={() => {
onPull?.()
onClose()
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '3px 8px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: 4,
fontSize: 11,
color: 'var(--foreground)',
cursor: 'pointer',
}}
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
className="h-6 gap-1 rounded-sm border-border bg-transparent px-2 text-[11px] text-foreground hover:bg-[var(--hover)]"
data-testid="git-status-pull-btn"
>
<ArrowDown size={11} />{translate(locale, 'status.sync.pull')}
</button>
</Button>
</div>
)
}
@@ -445,12 +447,18 @@ function PullAction({
function GitStatusPopup({
status,
remoteStatus,
repositories = [],
selectedRepositoryPath,
onRepositoryChange,
locale = 'en',
onPull,
onClose,
}: {
status: SyncStatus
remoteStatus: GitRemoteStatus | null
repositories?: GitRepositoryOption[]
selectedRepositoryPath?: string
onRepositoryChange?: (path: string) => void
locale?: AppLocale
onPull?: () => void
onClose: () => void
@@ -474,6 +482,17 @@ 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>
@@ -638,6 +657,9 @@ export function SyncBadge({
status,
lastSyncTime,
remoteStatus,
repositories,
selectedRepositoryPath,
onRepositoryChange,
onTriggerSync,
onPullAndPush,
onOpenConflictResolver,
@@ -647,6 +669,9 @@ export function SyncBadge({
status: SyncStatus
lastSyncTime: number | null
remoteStatus?: GitRemoteStatus | null
repositories?: GitRepositoryOption[]
selectedRepositoryPath?: string
onRepositoryChange?: (path: string) => void
onTriggerSync?: () => void
onPullAndPush?: () => void
onOpenConflictResolver?: () => void
@@ -656,6 +681,9 @@ 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))
@@ -678,13 +706,16 @@ 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 : formatSyncLabel(locale, status, lastSyncTime)}
{compact ? null : formatSyncBadgeLabel(locale, status, lastSyncTime, selectedRepositoryLabel)}
</span>
</StatusBarAction>
{showPopup && (
<GitStatusPopup
status={status}
remoteStatus={remoteStatus ?? null}
repositories={repositories}
selectedRepositoryPath={selectedRepositoryPath}
onRepositoryChange={onRepositoryChange}
locale={locale}
onPull={onTriggerSync}
onClose={() => setShowPopup(false)}

View File

@@ -31,6 +31,7 @@ import { ICON_STYLE, SEP_STYLE } from './styles'
import type { VaultOption } from './types'
import { VaultMenu } from './VaultMenu'
import { formatShortcutDisplay } from '../../hooks/appCommandCatalog'
import type { GitRepositoryOption } from '../../utils/gitRepositories'
const SETTINGS_SHORTCUT = {
shortcut: formatShortcutDisplay({ display: '⌘,' }),
@@ -66,6 +67,9 @@ interface StatusBarPrimarySectionProps {
lastSyncTime: number | null
conflictCount: number
remoteStatus?: GitRemoteStatus | null
repositories?: GitRepositoryOption[]
selectedRepositoryPath?: string
onRepositoryChange?: (path: string) => void
onTriggerSync?: () => void
onPullAndPush?: () => void
onOpenConflictResolver?: () => void
@@ -194,6 +198,9 @@ function StatusBarAiBadge({
function StatusBarPrimaryBadges({
modifiedCount,
visibleRemoteStatus,
repositories,
selectedRepositoryPath,
onRepositoryChange,
onAddRemote,
onClickPending,
onCommitPush,
@@ -227,6 +234,9 @@ function StatusBarPrimaryBadges({
}: {
modifiedCount: number
visibleRemoteStatus: GitRemoteStatus | null
repositories?: GitRepositoryOption[]
selectedRepositoryPath?: string
onRepositoryChange?: (path: string) => void
onAddRemote: () => void
onClickPending?: () => void
onCommitPush?: () => void
@@ -271,6 +281,9 @@ function StatusBarPrimaryBadges({
status={syncStatus}
lastSyncTime={lastSyncTime}
remoteStatus={visibleRemoteStatus}
repositories={repositories}
selectedRepositoryPath={selectedRepositoryPath}
onRepositoryChange={onRepositoryChange}
onTriggerSync={onTriggerSync}
onPullAndPush={onPullAndPush}
onOpenConflictResolver={onOpenConflictResolver}
@@ -408,6 +421,103 @@ function PrimarySeparator({ compact }: { compact: boolean }) {
return compact ? null : <span style={SEP_STYLE}>|</span>
}
function StatusBarGitControls({
modifiedCount,
vaultPath,
onAddRemote,
onClickPending,
onCommitPush,
commitActionPending,
gitFeaturesEnabled,
onInitializeGit,
isOffline,
isVaultReloading,
isGitVault,
syncStatus,
lastSyncTime,
conflictCount,
remoteStatus,
repositories,
selectedRepositoryPath,
onRepositoryChange,
onTriggerSync,
onPullAndPush,
onOpenConflictResolver,
onClickPulse,
mcpStatus,
onInstallMcp,
aiAgentsStatus,
vaultAiGuidanceStatus,
defaultAiAgent,
defaultAiTarget,
aiModelProviders,
onSetDefaultAiAgent,
onSetDefaultAiTarget,
onRestoreVaultAiGuidance,
claudeCodeStatus,
claudeCodeVersion,
compact,
locale,
}: StatusBarPrimarySectionProps & { compact: boolean; locale: AppLocale }) {
const gitVaultPath = selectedRepositoryPath || vaultPath
const { openAddRemote, closeAddRemote, showAddRemote, visibleRemoteStatus, handleRemoteConnected } = useStatusBarAddRemote({
vaultPath: gitVaultPath,
isGitVault: gitFeaturesEnabled !== false && isGitVault !== false,
remoteStatus,
onAddRemote,
})
return (
<>
<StatusBarPrimaryBadges
modifiedCount={modifiedCount}
visibleRemoteStatus={visibleRemoteStatus}
repositories={repositories}
selectedRepositoryPath={gitVaultPath}
onRepositoryChange={onRepositoryChange}
onAddRemote={() => {
void openAddRemote()
}}
onClickPending={onClickPending}
onCommitPush={onCommitPush}
commitActionPending={commitActionPending}
gitFeaturesEnabled={gitFeaturesEnabled !== false}
onInitializeGit={onInitializeGit}
syncStatus={syncStatus}
lastSyncTime={lastSyncTime}
onTriggerSync={onTriggerSync}
onPullAndPush={onPullAndPush}
onOpenConflictResolver={onOpenConflictResolver}
conflictCount={conflictCount}
onClickPulse={onClickPulse}
isGitVault={isGitVault !== false}
mcpStatus={mcpStatus}
onInstallMcp={onInstallMcp}
aiAgentsStatus={aiAgentsStatus}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
defaultAiAgent={defaultAiAgent}
defaultAiTarget={defaultAiTarget}
aiModelProviders={aiModelProviders}
onSetDefaultAiAgent={onSetDefaultAiAgent}
onSetDefaultAiTarget={onSetDefaultAiTarget}
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
claudeCodeStatus={claudeCodeStatus}
claudeCodeVersion={claudeCodeVersion}
isOffline={isOffline === true}
isVaultReloading={isVaultReloading === true}
compact={compact}
locale={locale}
/>
<AddRemoteModal
open={showAddRemote}
vaultPath={gitVaultPath}
onClose={closeAddRemote}
onRemoteConnected={handleRemoteConnected}
/>
</>
)
}
export function StatusBarPrimarySection({
modifiedCount,
vaultPath,
@@ -430,6 +540,9 @@ export function StatusBarPrimarySection({
lastSyncTime,
conflictCount,
remoteStatus,
repositories,
selectedRepositoryPath,
onRepositoryChange,
onTriggerSync,
onPullAndPush,
onOpenConflictResolver,
@@ -454,13 +567,6 @@ export function StatusBarPrimarySection({
stacked = false,
compact = false,
}: StatusBarPrimarySectionProps) {
const { openAddRemote, closeAddRemote, showAddRemote, visibleRemoteStatus, handleRemoteConnected } = useStatusBarAddRemote({
vaultPath,
isGitVault: gitFeaturesEnabled && isGitVault,
remoteStatus,
onAddRemote,
})
return (
<div style={primarySectionStyle(stacked, compact)}>
<VaultMenu
@@ -481,12 +587,16 @@ export function StatusBarPrimarySection({
/>
<PrimarySeparator compact={compact} />
<BuildNumberButton buildNumber={buildNumber} onCheckForUpdates={onCheckForUpdates} compact={compact} locale={locale} />
<StatusBarPrimaryBadges
<StatusBarGitControls
modifiedCount={modifiedCount}
visibleRemoteStatus={visibleRemoteStatus}
onAddRemote={() => {
void openAddRemote()
}}
vaultPath={vaultPath}
vaults={vaults}
onSwitchVault={onSwitchVault}
remoteStatus={remoteStatus}
repositories={repositories}
selectedRepositoryPath={selectedRepositoryPath}
onRepositoryChange={onRepositoryChange}
onAddRemote={onAddRemote}
onClickPending={onClickPending}
onCommitPush={onCommitPush}
commitActionPending={commitActionPending}
@@ -516,12 +626,6 @@ export function StatusBarPrimarySection({
compact={compact}
locale={locale}
/>
<AddRemoteModal
open={showAddRemote}
vaultPath={vaultPath}
onClose={closeAddRemote}
onRemoteConnected={handleRemoteConnected}
/>
</div>
)
}

View File

@@ -1,29 +1,65 @@
import type { CommandAction } from './types'
import type { SidebarSelection } from '../../types'
import type { GitRepositoryOption } from '../../utils/gitRepositories'
interface GitCommandsConfig {
modifiedCount: number
canAddRemote: boolean
gitFeaturesEnabled?: boolean
isGitVault?: boolean
repositories?: GitRepositoryOption[]
onAddRemote?: () => void
onCommitPush: () => void
onInitializeGit?: () => void
onPull?: () => void
onPullRepository?: (path: string) => void
onResolveConflicts?: () => void
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),
}))
}
return [
{ id: 'git-pull', label: 'Pull from Remote', group: 'Git', keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote'], enabled: true, execute: () => onPull?.() },
]
}
export function buildGitCommands(config: GitCommandsConfig): CommandAction[] {
const {
modifiedCount,
canAddRemote,
gitFeaturesEnabled = true,
isGitVault = true,
repositories,
onAddRemote,
onCommitPush,
onInitializeGit,
onPull,
onPullRepository,
onResolveConflicts,
onSelect,
} = config
@@ -46,7 +82,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?.() },
{ id: 'git-pull', label: 'Pull from Remote', group: 'Git', keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote'], enabled: true, execute: () => onPull?.() },
...buildPullCommands({ onPull, onPullRepository, repositories }),
{ 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' }) },
]

View File

@@ -159,6 +159,16 @@ function localizeSettingsStateCommand(command: CommandAction, t: Translate): str
return null
}
function localizeGitStateCommand(command: CommandAction, t: Translate): string | null {
if (command.id.startsWith('git-pull-')) {
return t('command.git.pullRepository', {
repository: stripKnownPrefix(command.label, 'Pull from Remote: '),
})
}
return null
}
function localizeTypeCommand(command: CommandAction, t: Translate): string | null {
if (command.id.startsWith('new-') && command.group === 'Note') {
return t('command.note.newTypedNote', { type: stripKnownPrefix(command.label, 'New ') })
@@ -184,6 +194,7 @@ export function localizeCommandActions(commands: CommandAction[], locale: AppLoc
: localizeNoteStateCommand(command, t)
?? localizeViewStateCommand(command, t)
?? localizeSettingsStateCommand(command, t)
?? localizeGitStateCommand(command, t)
?? localizeTypeCommand(command, t)
?? command.label
return label === command.label ? command : { ...command, label }

View File

@@ -272,6 +272,7 @@ export interface FrontmatterApplyCallbacks {
updateEntry: (path: VaultPath, patch: Partial<VaultEntry>) => void
toast: (message: ToastMessage) => void
getEntry?: (path: VaultPath) => VaultEntry | undefined
onMissingNotePath?: (path: VaultPath, error: unknown) => void | Promise<void>
shouldApply?: (path: VaultPath) => boolean
}
@@ -339,15 +340,52 @@ function failureToastMessage(op: FrontmatterOp): ToastMessage {
return `Failed to ${op} property`
}
function handleFrontmatterFailure(
op: FrontmatterOp,
err: unknown,
function frontmatterErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === 'string') return error
return String(error)
}
export function isMissingFrontmatterTargetError(error: unknown): boolean {
return /does not exist|not found|enoent/i.test(frontmatterErrorMessage(error))
}
async function runMissingNotePathCallback(
path: VaultPath,
error: unknown,
callbacks: FrontmatterApplyCallbacks,
options?: FrontmatterOpOptions,
): undefined {
console.error(`Failed to ${op} frontmatter:`, err)
): Promise<void> {
try {
await callbacks.onMissingNotePath?.(path, error)
} catch (callbackError) {
console.warn('Failed to handle missing frontmatter target:', callbackError)
}
}
interface FrontmatterFailureParams {
callbacks: FrontmatterApplyCallbacks
err: unknown
op: FrontmatterOp
options?: FrontmatterOpOptions
path: VaultPath
}
async function handleFrontmatterFailure({
callbacks,
err,
op,
options,
path,
}: FrontmatterFailureParams): Promise<undefined> {
const missingTarget = isMissingFrontmatterTargetError(err)
if (missingTarget) {
console.warn(`Skipped ${op} frontmatter for missing note:`, err)
await runMissingNotePathCallback(path, err, callbacks)
} else {
console.error(`Failed to ${op} frontmatter:`, err)
}
if (options?.silent) throw err
callbacks.toast(failureToastMessage(op))
if (!missingTarget) callbacks.toast(failureToastMessage(op))
return undefined
}
@@ -363,6 +401,6 @@ export async function runFrontmatterAndApply(request: FrontmatterRunRequest): Pr
notifyFrontmatterSuccess(op, callbacks, options)
return newContent
} catch (err) {
return handleFrontmatterFailure(op, err, callbacks, options)
return handleFrontmatterFailure({ op, path, err, callbacks, options })
}
}

View File

@@ -13,6 +13,7 @@ import { requestAddRemote } from '../utils/addRemoteEvents'
import type { NoteListFilter } from '../utils/noteListHelpers'
import type { ViewMode } from './useViewMode'
import type { NoteListMultiSelectionCommands } from '../components/note-list/multiSelectionCommands'
import type { GitRepositoryOption } from '../utils/gitRepositories'
interface AppCommandsConfig {
activeTabPath: string | null
@@ -38,6 +39,7 @@ interface AppCommandsConfig {
onUnarchiveNote: (path: string) => void
onCommitPush: () => void
onPull?: () => void
onPullRepository?: (path: string) => void
onResolveConflicts?: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
@@ -73,6 +75,7 @@ interface AppCommandsConfig {
canAddRemote?: boolean
gitFeaturesEnabled?: boolean
isGitVault?: boolean
gitRepositories?: GitRepositoryOption[]
onInitializeGit?: () => void
onCreateType?: () => void
aiFeaturesEnabled?: boolean
@@ -164,6 +167,7 @@ type CommandRegistryCoreActions = Pick<
| 'onUnarchiveNote'
| 'onCommitPush'
| 'onPull'
| 'onPullRepository'
| 'onResolveConflicts'
| 'onSetViewMode'
| 'onToggleInspector'
@@ -189,6 +193,7 @@ type CommandRegistryVaultActions = Pick<
| 'canAddRemote'
| 'gitFeaturesEnabled'
| 'isGitVault'
| 'gitRepositories'
| 'onInitializeGit'
| 'onCheckForUpdates'
| 'onCreateType'
@@ -451,6 +456,7 @@ function createCommandRegistryCoreConfig(
onUnarchiveNote: config.onUnarchiveNote,
onCommitPush: config.onCommitPush,
onPull: config.onPull,
onPullRepository: config.onPullRepository,
onResolveConflicts: config.onResolveConflicts,
onSetViewMode: config.onSetViewMode,
onToggleInspector: config.onToggleInspector,
@@ -483,6 +489,7 @@ function createCommandRegistryVaultConfig(
canAddRemote: config.canAddRemote ?? true,
gitFeaturesEnabled: config.gitFeaturesEnabled,
isGitVault: config.isGitVault,
gitRepositories: config.gitRepositories,
onInitializeGit: config.onInitializeGit,
onCheckForUpdates: config.onCheckForUpdates,
onCreateType: config.onCreateType,

View File

@@ -120,7 +120,7 @@ describe('useAutoSync extra', () => {
})
await waitFor(() => {
expect(hook.onVaultUpdated).toHaveBeenCalledWith(['notes/today.md'])
expect(hook.onVaultUpdated).toHaveBeenCalledWith(['notes/today.md'], '/Users/luca/Laputa')
expect(hook.onSyncUpdated).toHaveBeenCalledOnce()
expect(hook.onToast).toHaveBeenCalledWith('Pulled and pushed successfully')
expect(hook.result.current.syncStatus).toBe('idle')

View File

@@ -96,7 +96,7 @@ describe('useAutoSync', () => {
const { result } = renderSync()
await waitFor(() => {
expect(onVaultUpdated).toHaveBeenCalledWith(['note.md', 'project/plan.md'])
expect(onVaultUpdated).toHaveBeenCalledWith(['note.md', 'project/plan.md'], '/Users/luca/Laputa')
expect(onToast).toHaveBeenCalledWith('Pulled 2 update(s) from remote')
expect(result.current.syncStatus).toBe('idle')
})
@@ -124,7 +124,7 @@ describe('useAutoSync', () => {
)
await waitFor(() => {
expect(asyncVaultRefresh).toHaveBeenCalledWith(['note.md'])
expect(asyncVaultRefresh).toHaveBeenCalledWith(['note.md'], '/Users/luca/Laputa')
})
expect(onToast).not.toHaveBeenCalledWith('Pulled 1 update(s) from remote')
@@ -417,7 +417,7 @@ describe('useAutoSync', () => {
})
await waitFor(() => {
expect(onVaultUpdated).toHaveBeenCalledWith(['note.md'])
expect(onVaultUpdated).toHaveBeenCalledWith(['note.md'], '/Users/luca/Laputa')
expect(onSyncUpdated).toHaveBeenCalled()
expect(onToast).toHaveBeenCalledWith('Pulled and pushed successfully')
expect(result.current.syncStatus).toBe('idle')
@@ -459,6 +459,29 @@ describe('useAutoSync', () => {
})
})
it('manual triggerSync targets an explicit repository path', async () => {
const { result } = renderSync()
await waitFor(() => {
expect(result.current.syncStatus).toBe('idle')
})
mockInvokeFn.mockClear()
mockInvokeFn.mockImplementation((cmd: 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 })
return Promise.resolve(updated(['work.md']))
})
await act(async () => {
result.current.triggerSync('/Users/luca/Work')
})
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Work' })
expect(onVaultUpdated).toHaveBeenCalledWith(['work.md'], '/Users/luca/Work')
})
})
it('surfaces pull conflicts and pull errors during recovery pushes', async () => {
let mode: 'conflict' | 'error' = 'conflict'

View File

@@ -20,7 +20,7 @@ interface UseAutoSyncOptions {
enabled?: boolean
vaultPath: string
intervalMinutes: number | null
onVaultUpdated: (updatedFiles: string[]) => MaybePromise
onVaultUpdated: (updatedFiles: string[], vaultPath: string) => MaybePromise
onSyncUpdated?: () => MaybePromise
onConflict: (files: string[]) => void
onToast: (msg: string) => void
@@ -32,9 +32,9 @@ export interface AutoSyncState {
conflictFiles: string[]
lastCommitInfo: LastCommitInfo | null
remoteStatus: GitRemoteStatus | null
triggerSync: () => void
triggerSync: (vaultPath?: string) => void
/** Pull from remote, then push if there are local commits ahead. */
pullAndPush: () => void
pullAndPush: (vaultPath?: string) => void
/** Pause auto-pull (e.g. while conflict resolver modal is open). */
pausePull: () => void
/** Resume auto-pull after pausing. */
@@ -82,19 +82,20 @@ function setConflictState(
function markPullTimestamp(
setLastSyncTime: SyncSetState<number | null>,
refreshCommitInfo: () => void,
refreshCommitInfo: (vaultPath?: string) => void,
vaultPath?: string,
): void {
setLastSyncTime(Date.now())
refreshCommitInfo()
refreshCommitInfo(vaultPath)
}
function useRemoteStatusRefresher(
vaultPath: string,
setRemoteStatus: SyncSetState<GitRemoteStatus | null>,
) {
return useCallback(async () => {
return useCallback(async (targetVaultPath = vaultPath) => {
try {
const status = await tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
const status = await tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath: targetVaultPath })
setRemoteStatus(status)
return status
} catch {
@@ -109,9 +110,9 @@ function useConflictChecker(
setConflictFiles: SyncSetState<string[]>,
callbacksRef: MutableRefObject<SyncCallbacks>,
) {
return useCallback(async (): Promise<boolean> => {
return useCallback(async (targetVaultPath = vaultPath): Promise<boolean> => {
try {
const files = await tauriCall<string[]>('get_conflict_files', { vaultPath })
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
@@ -125,8 +126,8 @@ function useCommitInfoRefresher(
vaultPath: string,
setLastCommitInfo: SyncSetState<LastCommitInfo | null>,
) {
return useCallback(() => {
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
return useCallback((targetVaultPath = vaultPath) => {
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath: targetVaultPath })
.then(info => setLastCommitInfo(info))
.catch((err) => console.warn('[sync] Failed to refresh last commit info:', err))
}, [vaultPath, setLastCommitInfo])
@@ -134,18 +135,20 @@ function useCommitInfoRefresher(
async function handleUpdatedPull(options: {
result: GitPullResult
vaultPath: string
callbacksRef: MutableRefObject<SyncCallbacks>
setConflictFiles: SyncSetState<string[]>
setSyncStatus: SyncSetState<SyncStatus>
}): Promise<void> {
const {
result,
vaultPath,
callbacksRef,
setConflictFiles,
setSyncStatus,
} = options
clearConflictState(setSyncStatus, setConflictFiles)
await callbacksRef.current.onVaultUpdated(result.updatedFiles)
await callbacksRef.current.onVaultUpdated(result.updatedFiles, vaultPath)
await callbacksRef.current.onSyncUpdated?.()
await callbacksRef.current.onToast(`Pulled ${result.updatedFiles.length} update(s) from remote`)
}
@@ -284,7 +287,7 @@ export function useAutoSync({
const checkExistingConflicts = useConflictChecker(vaultPath, setSyncStatus, setConflictFiles, callbacksRef)
const refreshCommitInfo = useCommitInfoRefresher(vaultPath, setLastCommitInfo)
const performPull = useCallback(async () => {
const performPull = useCallback(async (targetVaultPath = vaultPath) => {
if (!enabled) return
await runSyncTask({
@@ -294,12 +297,13 @@ export function useAutoSync({
setLastSyncTime,
setSyncStatus,
task: async () => {
const result = await tauriCall<GitPullResult>('git_pull', { vaultPath })
markPullTimestamp(setLastSyncTime, refreshCommitInfo)
const result = await tauriCall<GitPullResult>('git_pull', { vaultPath: targetVaultPath })
markPullTimestamp(setLastSyncTime, refreshCommitInfo, targetVaultPath)
if (result.status === 'updated') {
await handleUpdatedPull({
result,
vaultPath: targetVaultPath,
callbacksRef,
setConflictFiles,
setSyncStatus,
@@ -308,7 +312,7 @@ export function useAutoSync({
setConflictState(result.conflictFiles, setSyncStatus, setConflictFiles, callbacksRef)
} else if (result.status === 'error') {
await resolvePullError({
checkExistingConflicts,
checkExistingConflicts: () => checkExistingConflicts(targetVaultPath),
callbacksRef,
setSyncStatus,
})
@@ -316,13 +320,13 @@ export function useAutoSync({
clearConflictState(setSyncStatus, setConflictFiles)
}
void refreshRemoteStatus()
void refreshRemoteStatus(targetVaultPath)
},
})
}, [enabled, vaultPath, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus])
/** Pull from remote, then auto-push if successful. Used for divergence recovery. */
const pullAndPush = useCallback(async () => {
const pullAndPush = useCallback(async (targetVaultPath = vaultPath) => {
if (!enabled) return
await runSyncTask({
@@ -332,8 +336,8 @@ export function useAutoSync({
setLastSyncTime,
setSyncStatus,
task: async () => {
const pullResult = await tauriCall<GitPullResult>('git_pull', { vaultPath })
markPullTimestamp(setLastSyncTime, refreshCommitInfo)
const pullResult = await tauriCall<GitPullResult>('git_pull', { vaultPath: targetVaultPath })
markPullTimestamp(setLastSyncTime, refreshCommitInfo, targetVaultPath)
if (pullResult.status === 'conflict') {
setConflictState(pullResult.conflictFiles, setSyncStatus, setConflictFiles, callbacksRef)
@@ -342,7 +346,7 @@ export function useAutoSync({
if (pullResult.status === 'error') {
await resolvePullError({
checkExistingConflicts,
checkExistingConflicts: () => checkExistingConflicts(targetVaultPath),
notifyError: `Pull failed: ${pullResult.message}`,
callbacksRef,
setSyncStatus,
@@ -351,11 +355,11 @@ export function useAutoSync({
}
if (pullResult.status === 'updated') {
await callbacksRef.current.onVaultUpdated(pullResult.updatedFiles)
await callbacksRef.current.onVaultUpdated(pullResult.updatedFiles, targetVaultPath)
await callbacksRef.current.onSyncUpdated?.()
}
const pushResult = await tauriCall<GitPushResult>('git_push', { vaultPath })
const pushResult = await tauriCall<GitPushResult>('git_push', { vaultPath: targetVaultPath })
handlePushResult({
pushResult,
callbacksRef,
@@ -363,7 +367,7 @@ export function useAutoSync({
setSyncStatus,
})
void refreshRemoteStatus()
void refreshRemoteStatus(targetVaultPath)
},
})
}, [enabled, vaultPath, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus])
@@ -383,12 +387,12 @@ export function useAutoSync({
const pausePull = useCallback(() => { pauseRef.current = true }, [])
const resumePull = useCallback(() => { pauseRef.current = false }, [])
const triggerSync = useCallback(() => {
const triggerSync = useCallback((targetVaultPath = vaultPath) => {
if (!enabled) return
trackEvent('sync_triggered')
void performPull()
}, [enabled, performPull])
void performPull(targetVaultPath)
}, [enabled, performPull, vaultPath])
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync, pullAndPush, pausePull, resumePull, handlePushRejected }
}

View File

@@ -151,6 +151,26 @@ describe('useCommandRegistry', () => {
expect(findCommand(result.current, 'view-changes')).toBeUndefined()
})
it('exposes one pull command per active repository when multiple Git targets are available', () => {
const onPullRepository = vi.fn()
const config = makeConfig({
gitRepositories: [
{ path: '/vault/main', label: 'Main Vault', defaultForNewNotes: true },
{ path: '/vault/brian', label: 'Brian', defaultForNewNotes: false },
],
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')
brianPull?.execute()
expect(onPullRepository).toHaveBeenCalledWith('/vault/brian')
})
it('resolve-conflicts stays enabled across rerenders', () => {
const config = makeConfig()
const { result, rerender } = renderHook(

View File

@@ -16,6 +16,7 @@ import { buildTypeCommands } from './commands/typeCommands'
import { buildFilterCommands } from './commands/filterCommands'
import { localizeCommandActions } from './commands/localizeCommands'
import { extractVaultTypes } from '../utils/vaultTypes'
import type { GitRepositoryOption } from '../utils/gitRepositories'
// Re-export types and helpers for backward compatibility
export type { CommandAction, CommandGroup } from './commands/types'
@@ -76,6 +77,7 @@ interface CommandRegistryConfig {
canAddRemote?: boolean
gitFeaturesEnabled?: boolean
isGitVault?: boolean
gitRepositories?: GitRepositoryOption[]
onInitializeGit?: () => void
onCreateType?: () => void
onDeleteNote: (path: string) => void
@@ -83,6 +85,7 @@ interface CommandRegistryConfig {
onUnarchiveNote: (path: string) => void
onCommitPush: () => void
onPull?: () => void
onPullRepository?: (path: string) => void
onResolveConflicts?: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
@@ -151,7 +154,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onCustomizeNoteListColumns, canCustomizeNoteListColumns,
onRestoreDeletedNote, canRestoreDeletedNote,
selection, noteListFilter, onSetNoteListFilter,
gitFeaturesEnabled, isGitVault, onInitializeGit,
gitFeaturesEnabled, isGitVault, gitRepositories, onInitializeGit, onPullRepository,
} = config
const hasActiveNote = activeTabPath !== null
@@ -215,16 +218,18 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
modifiedCount,
gitFeaturesEnabled,
isGitVault,
repositories: gitRepositories,
canAddRemote: config.canAddRemote ?? false,
onAddRemote: config.onAddRemote,
onCommitPush,
onInitializeGit,
onPull,
onPullRepository,
onResolveConflicts,
onSelect,
}), [
modifiedCount, gitFeaturesEnabled, isGitVault, config.canAddRemote, config.onAddRemote,
onCommitPush, onInitializeGit, onPull, onResolveConflicts, onSelect,
modifiedCount, gitFeaturesEnabled, isGitVault, gitRepositories, config.canAddRemote, config.onAddRemote,
onCommitPush, onInitializeGit, onPull, onPullRepository, onResolveConflicts, onSelect,
])
const viewCommands = useMemo(() => buildViewCommands({

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react'
import type { VaultEntry } from '../types'
import type { FrontmatterOpOptions } from './frontmatterOps'
import { isMissingFrontmatterTargetError, type FrontmatterOpOptions } from './frontmatterOps'
import { trackEvent } from '../lib/telemetry'
interface EntryActionsConfig {
@@ -53,6 +53,14 @@ function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | un
return entries.find((entry) => entry.isA === 'Type' && entry.title === typeName)
}
function logOptimisticRollback(label: string, error: unknown): void {
if (isMissingFrontmatterTargetError(error)) {
console.warn(label, error)
return
}
console.error(label, error)
}
async function findOrCreateType(
deps: Pick<TypeActionDeps, 'entries' | 'createTypeEntry'>,
typeName: string,
@@ -139,7 +147,7 @@ function useArchiveActions({
} catch (err) {
updateEntry(path, { archived: false })
setToastMessage('Failed to archive note — rolled back')
console.error('Optimistic archive rollback:', err)
logOptimisticRollback('Optimistic archive rollback:', err)
}
}, [onBeforeAction, handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
@@ -153,7 +161,7 @@ function useArchiveActions({
} catch (err) {
updateEntry(path, { archived: true })
setToastMessage('Failed to unarchive note — rolled back')
console.error('Optimistic unarchive rollback:', err)
logOptimisticRollback('Optimistic unarchive rollback:', err)
}
}, [handleDeleteProperty, updateEntry, setToastMessage, onFrontmatterPersisted])

View File

@@ -118,4 +118,27 @@ describe('useGitRepositories', () => {
expect(mockInvoke).toHaveBeenCalledWith('get_modified_files', { vaultPath: '/default', includeStats: true })
})
it('keeps an explicit sync repository selection separate from other Git surfaces', async () => {
const repositories = [
{ path: '/default', label: 'Default', defaultForNewNotes: true },
{ path: '/work', label: 'Work', defaultForNewNotes: false },
]
vi.mocked(mockInvoke).mockResolvedValue([])
const { result } = renderHook(() => useGitRepositories({
defaultVaultPath: '/default',
repositories,
}))
await waitFor(() => expect(mockInvoke).toHaveBeenCalledWith('get_modified_files', { vaultPath: '/default', includeStats: false }))
act(() => {
result.current.setSyncRepositoryPath('/work')
})
expect(result.current.syncRepositoryPath).toBe('/work')
expect(result.current.commitRepositoryPath).toBe('/default')
expect(result.current.changesRepositoryPath).toBe('/default')
})
})

View File

@@ -181,6 +181,7 @@ export function useGitRepositories({
const [changesRepositoryPath, setChangesRepositoryPath] = useValidatedRepositoryPath(selectionConfig)
const [historyRepositoryPath, setHistoryRepositoryPath] = useValidatedRepositoryPath(selectionConfig)
const [commitRepositoryPath, setCommitRepositoryPath] = useValidatedRepositoryPath(selectionConfig)
const [syncRepositoryPath, setSyncRepositoryPath] = useValidatedRepositoryPath(selectionConfig)
const { byRepository, loadAllModifiedFiles, loadModifiedFilesForRepository } = useRepositoryModifiedFiles(repositories)
const {
byRepository: remoteStatusByRepository,
@@ -234,6 +235,8 @@ export function useGitRepositories({
setChangesRepositoryPath,
setCommitRepositoryPath,
setHistoryRepositoryPath,
setSyncRepositoryPath,
syncRepositoryPath,
totalModifiedCount: allModifiedFiles.length,
}
}

View File

@@ -1,9 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import { useNoteActions, type NoteActionsConfig } from './useNoteActions'
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('../mock-tauri', () => ({
isTauri: vi.fn(() => false),
addMockEntry: vi.fn(),
@@ -59,9 +61,34 @@ function makeConfig(overrides: Partial<NoteActionsConfig> = {}): NoteActionsConf
}
}
async function openNoteForFrontmatterRecovery(
result: { current: ReturnType<typeof useNoteActions> },
entry: VaultEntry,
) {
vi.mocked(mockInvoke).mockResolvedValue('# Missing Note\n')
await act(async () => {
await result.current.handleSelectNote(entry)
})
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockRejectedValueOnce(new Error('File does not exist: /test/vault/missing.md'))
}
function expectMissingFrontmatterRecovery(
result: { current: ReturnType<typeof useNoteActions> },
config: NoteActionsConfig,
) {
expect(result.current.tabs).toEqual([])
expect(result.current.activeTabPath).toBeNull()
expect(config.reloadVault).toHaveBeenCalledTimes(1)
expect(config.setToastMessage).toHaveBeenCalledWith(
'"Missing Note" could not be opened because its file is missing or moved.',
)
}
describe('useNoteActions missing-path recovery', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(invoke).mockReset()
vi.mocked(isTauri).mockReturnValue(false)
})
@@ -129,4 +156,44 @@ describe('useNoteActions missing-path recovery', () => {
)
warnSpy.mockRestore()
})
it('reloads and clears the active tab when a frontmatter write targets a deleted note file', async () => {
const entry = makeEntry()
const config = makeConfig({ entries: [entry] })
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { result } = renderHook(() => useNoteActions(config))
await openNoteForFrontmatterRecovery(result, entry)
await act(async () => {
await result.current.handleUpdateFrontmatter(entry.path, 'status', 'Done')
})
expectMissingFrontmatterRecovery(result, config)
expect(config.updateEntry).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it('still rejects silent frontmatter writes after running missing-note recovery', async () => {
const entry = makeEntry()
const config = makeConfig({ entries: [entry] })
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { result } = renderHook(() => useNoteActions(config))
await openNoteForFrontmatterRecovery(result, entry)
let thrown: unknown
await act(async () => {
try {
await result.current.handleUpdateFrontmatter(entry.path, '_archived', true, { silent: true })
} catch (err) {
thrown = err
}
})
expect(thrown).toBeInstanceOf(Error)
expectMissingFrontmatterRecovery(result, config)
warnSpy.mockRestore()
})
})

View File

@@ -315,6 +315,28 @@ function buildTabManagementOptions(
return options
}
function handleMissingFrontmatterTarget({
activeTabPathRef,
closeAllTabs,
entries,
path,
reloadVault,
setToastMessage,
}: {
activeTabPathRef: MutableRefObject<string | null>
closeAllTabs: () => void
entries: VaultEntry[]
path: string
reloadVault?: () => Promise<unknown>
setToastMessage: NoteActionsConfig['setToastMessage']
}) {
const entry = findByNotePath(entries, path)
const label = entry ? entryDisplayLabel(entry) : notePathFilename(path) || 'Note'
if (notePathsMatch(activeTabPathRef.current, path)) closeAllTabs()
setToastMessage(`"${label}" could not be opened because its file is missing or moved.`)
void reloadVault?.()
}
function useGitignoredVisibilityTabCleanup({
activeTabPathRef,
closeAllTabs,
@@ -430,13 +452,17 @@ function useFrontmatterActionHandlers({
function useFrontmatterRunner({
activeTabPathRef,
closeAllTabs,
entries,
reloadVault,
setToastMessage,
updateEntry,
updateTabContent,
}: {
activeTabPathRef: MutableRefObject<string | null>
closeAllTabs: () => void
entries: VaultEntry[]
reloadVault?: () => Promise<unknown>
setToastMessage: NoteActionsConfig['setToastMessage']
updateEntry: NoteActionsConfig['updateEntry']
updateTabContent: (path: string, newContent: string) => void
@@ -452,11 +478,19 @@ function useFrontmatterRunner({
updateEntry,
toast: setToastMessage,
getEntry: (p) => findByNotePath(entries, p),
onMissingNotePath: (p) => handleMissingFrontmatterTarget({
activeTabPathRef,
closeAllTabs,
entries,
path: p,
reloadVault,
setToastMessage,
}),
shouldApply: (p) => activePathGuardAllowsMutation(p, activeTabPathRef, options),
},
options,
}),
[activeTabPathRef, entries, setToastMessage, updateEntry, updateTabContent],
[activeTabPathRef, closeAllTabs, entries, reloadVault, setToastMessage, updateEntry, updateTabContent],
)
}
@@ -502,7 +536,15 @@ export function useNoteActions(config: NoteActionsConfig) {
[entries, handleSelectNote, tabMgmt.activeTabPath, tabMgmt.tabs],
)
const runFrontmatterOp = useFrontmatterRunner({ activeTabPathRef, entries, setToastMessage, updateEntry, updateTabContent })
const runFrontmatterOp = useFrontmatterRunner({
activeTabPathRef,
closeAllTabs: tabMgmt.closeAllTabs,
entries,
reloadVault: config.reloadVault,
setToastMessage,
updateEntry,
updateTabContent,
})
const frontmatterActions = useFrontmatterActionHandlers({
config,
onPathRenamed: handlePathRenamed,

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "Commit & Push",
"command.git.addRemote": "Remote zum aktuellen Vault hinzufügen",
"command.git.pull": "Von Remote abrufen",
"command.git.pullRepository": "Aus Remote abrufen: {repository}",
"command.git.resolveConflicts": "Konflikte auflösen",
"command.git.viewChanges": "Ausstehende Änderungen anzeigen",
"git.repository.select": "Repository",

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "Commit & Push",
"command.git.addRemote": "Add Remote to Current Vault",
"command.git.pull": "Pull from Remote",
"command.git.pullRepository": "Pull from Remote: {repository}",
"command.git.resolveConflicts": "Resolve Conflicts",
"command.git.viewChanges": "View Pending Changes",
"git.repository.select": "Repository",

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "Confirmar y enviar",
"command.git.addRemote": "Agregar repositorio remoto al repositorio actual",
"command.git.pull": "Extraer desde el repositorio remoto",
"command.git.pullRepository": "Extraer del repositorio remoto: {repository}",
"command.git.resolveConflicts": "Resolver conflictos",
"command.git.viewChanges": "Ver cambios pendientes",
"git.repository.select": "Repositorio",

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "Confirmar y enviar",
"command.git.addRemote": "Añadir repositorio remoto al almacén actual",
"command.git.pull": "Extraer del repositorio remoto",
"command.git.pullRepository": "Extraer del repositorio remoto: {repository}",
"command.git.resolveConflicts": "Resolver conflictos",
"command.git.viewChanges": "Ver cambios pendientes",
"git.repository.select": "Repositorio",

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "Valider et pousser",
"command.git.addRemote": "Ajouter le dépôt distant au coffre-fort actuel",
"command.git.pull": "Extraire depuis le dépôt distant",
"command.git.pullRepository": "Extraire du dépôt distant : {repository}",
"command.git.resolveConflicts": "Résoudre les conflits",
"command.git.viewChanges": "Afficher les modifications en attente",
"git.repository.select": "Dépôt",

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "Commit e push",
"command.git.addRemote": "Aggiungi repository remoto al vault corrente",
"command.git.pull": "Esegui il pull dal repository remoto",
"command.git.pullRepository": "Esegui il pull dal repository remoto: {repository}",
"command.git.resolveConflicts": "Risolvi conflitti",
"command.git.viewChanges": "Visualizza modifiche in sospeso",
"git.repository.select": "Repository",

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "コミット & プッシュ",
"command.git.addRemote": "現在のボールトにリモートを追加",
"command.git.pull": "リモートからプル",
"command.git.pullRepository": "リモートからプル:{repository}",
"command.git.resolveConflicts": "競合を解決",
"command.git.viewChanges": "保留中の変更を表示",
"git.repository.select": "リポジトリ",

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "커밋 및 푸시",
"command.git.addRemote": "현재 Vault에 원격 저장소 추가",
"command.git.pull": "원격에서 가져오기",
"command.git.pullRepository": "원격에서 가져오기: {repository}",
"command.git.resolveConflicts": "충돌 해결",
"command.git.viewChanges": "보류 중인 변경 사항 보기",
"git.repository.select": "저장소",

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "Zatwierdź i wypchnij",
"command.git.addRemote": "Dodaj zdalne repozytorium do bieżącego sejfu",
"command.git.pull": "Pobierz ze zdalnego repozytorium",
"command.git.pullRepository": "Pobierz z repozytorium zdalnego: {repository}",
"command.git.resolveConflicts": "Rozwiąż konflikty",
"command.git.viewChanges": "Pokaż oczekujące zmiany",
"git.repository.select": "Repozytorium",

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "Fazer commit e push",
"command.git.addRemote": "Adicionar repositório remoto ao cofre atual",
"command.git.pull": "Fazer pull do repositório remoto",
"command.git.pullRepository": "Puxar do Remoto: {repository}",
"command.git.resolveConflicts": "Resolver conflitos",
"command.git.viewChanges": "Exibir alterações pendentes",
"git.repository.select": "Repositório",

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "Fazer commit e push",
"command.git.addRemote": "Adicionar repositório remoto ao cofre atual",
"command.git.pull": "Efetuar pull do repositório remoto",
"command.git.pullRepository": "Obter do Remoto: {repository}",
"command.git.resolveConflicts": "Resolver conflitos",
"command.git.viewChanges": "Ver alterações pendentes",
"git.repository.select": "Repositório",

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "Commit и Push",
"command.git.addRemote": "Добавить удаленный репозиторий в текущий хранилище",
"command.git.pull": "Pull from Remote",
"command.git.pullRepository": "Получить из удаленного репозитория: {repository}",
"command.git.resolveConflicts": "Разрешить конфликты",
"command.git.viewChanges": "Просмотреть ожидающие изменения",
"git.repository.select": "Репозиторий",

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "Commit & Push",
"command.git.addRemote": "Thêm remote cho kho hiện tại",
"command.git.pull": "Kéo từ remote",
"command.git.pullRepository": "Lấy từ Remote: {repository}",
"command.git.resolveConflicts": "Giải quyết xung đột",
"command.git.viewChanges": "Xem các thay đổi đang chờ",
"git.repository.select": "Kho lưu trữ",

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "提交并推送",
"command.git.addRemote": "为当前仓库添加远程地址",
"command.git.pull": "从远程拉取",
"command.git.pullRepository": "从远程拉取:{repository}",
"command.git.resolveConflicts": "解决冲突",
"command.git.viewChanges": "查看待处理更改",
"git.repository.select": "仓库",

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "提交併推送",
"command.git.addRemote": "為當前倉庫新增遠端地址",
"command.git.pull": "從遠端拉取",
"command.git.pullRepository": "從遠端提取:{repository}",
"command.git.resolveConflicts": "解決衝突",
"command.git.viewChanges": "檢視待處理更改",
"git.repository.select": "儲存庫",