feat: add migration wizard for flat vault

On vault load, detects files in non-protected subfolders via
vault_health_check. Shows an amber banner offering to flatten them
to the vault root. After migration, reloads the vault automatically.
The banner can be dismissed without migrating.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-16 04:48:44 +01:00
parent aad863ebe3
commit 6237efe02f
3 changed files with 122 additions and 0 deletions

View File

@@ -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() {
/>
</div>
</div>
{flatVaultMigration.needsMigration && (
<FlatVaultMigrationBanner
strayFileCount={flatVaultMigration.strayFiles.length}
isMigrating={flatVaultMigration.isMigrating}
onMigrate={async () => {
const count = await flatVaultMigration.migrate()
setToastMessage(`Migrated ${count} file${count !== 1 ? 's' : ''} to vault root`)
}}
onDismiss={flatVaultMigration.dismiss}
/>
)}
<UpdateBanner status={updateStatus} actions={updateActions} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => 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} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />

View File

@@ -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 (
<div className="flex items-center gap-3 px-4 py-2 bg-amber-50 dark:bg-amber-950 border-b border-amber-200 dark:border-amber-800 text-sm" data-testid="migration-banner">
<span className="flex-1 text-amber-800 dark:text-amber-200">
{strayFileCount} note{strayFileCount !== 1 ? 's' : ''} found in subfolders.
Flatten to vault root for consistent scanning.
</span>
<button
className="px-3 py-1 text-xs font-medium rounded bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-50"
onClick={onMigrate}
disabled={isMigrating}
data-testid="migration-flatten-btn"
>
{isMigrating ? 'Migrating...' : 'Flatten Now'}
</button>
<button
className="px-2 py-1 text-xs text-amber-600 dark:text-amber-400 hover:underline"
onClick={onDismiss}
disabled={isMigrating}
data-testid="migration-dismiss-btn"
>
Dismiss
</button>
</div>
)
}

View File

@@ -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<number>
/** 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<void>,
): FlatVaultMigration {
const [strayFiles, setStrayFiles] = useState<string[]>([])
const [dismissed, setDismissed] = useState(false)
const [isMigrating, setIsMigrating] = useState(false)
useEffect(() => {
if (!entriesLoaded || !vaultPath || !isTauri()) return
let cancelled = false
invoke<HealthReport>('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<number>('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,
}
}