diff --git a/src/App.tsx b/src/App.tsx index 8e48f05e..5259db79 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,6 +43,8 @@ import { useLayoutPanels } from './hooks/useLayoutPanels' import { ConflictResolverModal } from './components/ConflictResolverModal' import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog' import { UpdateBanner } from './components/UpdateBanner' +import { FlatVaultMigrationBanner } from './components/FlatVaultMigrationBanner' +import { useFlatVaultMigration } from './hooks/useFlatVaultMigration' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from './mock-tauri' import type { SidebarSelection, VaultEntry } from './types' @@ -87,6 +89,7 @@ function App() { const { settings, saveSettings } = useSettings() const themeManager = useThemeManager(resolvedPath, vault.entries) + const flatVaultMigration = useFlatVaultMigration(resolvedPath, vault.entries.length > 0, vault.reloadVault) const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage) const indexing = useIndexing(resolvedPath) @@ -576,6 +579,17 @@ function App() { /> + {flatVaultMigration.needsMigration && ( + { + const count = await flatVaultMigration.migrate() + setToastMessage(`Migrated ${count} file${count !== 1 ? 's' : ''} to vault root`) + }} + onDismiss={flatVaultMigration.dismiss} + /> + )} setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} /> setToastMessage(null)} /> diff --git a/src/components/FlatVaultMigrationBanner.tsx b/src/components/FlatVaultMigrationBanner.tsx new file mode 100644 index 00000000..0b185636 --- /dev/null +++ b/src/components/FlatVaultMigrationBanner.tsx @@ -0,0 +1,37 @@ +interface FlatVaultMigrationBannerProps { + strayFileCount: number + isMigrating: boolean + onMigrate: () => void + onDismiss: () => void +} + +/** + * Banner shown when the vault has files in non-protected subfolders. + * Offers to flatten them to the vault root. + */ +export function FlatVaultMigrationBanner({ strayFileCount, isMigrating, onMigrate, onDismiss }: FlatVaultMigrationBannerProps) { + return ( +
+ + {strayFileCount} note{strayFileCount !== 1 ? 's' : ''} found in subfolders. + Flatten to vault root for consistent scanning. + + + +
+ ) +} diff --git a/src/hooks/useFlatVaultMigration.ts b/src/hooks/useFlatVaultMigration.ts new file mode 100644 index 00000000..b8982759 --- /dev/null +++ b/src/hooks/useFlatVaultMigration.ts @@ -0,0 +1,71 @@ +import { useState, useEffect, useCallback } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { isTauri } from '../mock-tauri' + +interface HealthReport { + stray_files: string[] + title_mismatches: { path: string; filename: string; title: string; expected_filename: string }[] +} + +interface FlatVaultMigration { + /** True if stray files were detected in non-protected subfolders. */ + needsMigration: boolean + /** List of stray file paths (relative to vault root). */ + strayFiles: string[] + /** Dismiss the migration prompt without migrating. */ + dismiss: () => void + /** Run flatten_vault and reload. Returns the count of files moved. */ + migrate: () => Promise + /** True while migration is running. */ + isMigrating: boolean +} + +/** + * Detects if the vault has files in non-protected subfolders and offers + * to flatten them to the vault root. Runs once on vault load. + */ +export function useFlatVaultMigration( + vaultPath: string, + entriesLoaded: boolean, + reloadVault: () => Promise, +): FlatVaultMigration { + const [strayFiles, setStrayFiles] = useState([]) + const [dismissed, setDismissed] = useState(false) + const [isMigrating, setIsMigrating] = useState(false) + + useEffect(() => { + if (!entriesLoaded || !vaultPath || !isTauri()) return + let cancelled = false + invoke('vault_health_check', { vaultPath }) + .then((report) => { + if (!cancelled && report.stray_files.length > 0) { + setStrayFiles(report.stray_files) + } + }) + .catch(() => { /* non-critical */ }) + return () => { cancelled = true } + }, [vaultPath, entriesLoaded]) + + const dismiss = useCallback(() => setDismissed(true), []) + + const migrate = useCallback(async () => { + setIsMigrating(true) + try { + const count = await invoke('flatten_vault', { vaultPath }) + setStrayFiles([]) + setDismissed(true) + await reloadVault() + return count + } finally { + setIsMigrating(false) + } + }, [vaultPath, reloadVault]) + + return { + needsMigration: strayFiles.length > 0 && !dismissed, + strayFiles, + dismiss, + migrate, + isMigrating, + } +}