From fd100e1c3bd5456680df72b1242e7d5d7e5b90a7 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 2 Mar 2026 23:25:54 +0100 Subject: [PATCH] fix: sync changes badge count with list by invalidating stale vault cache When .laputa-cache.json was committed to git, cloned vaults carried absolute paths from the original machine. The badge (from get_modified_files) used fresh local paths while the list filtered entries by stale cached paths, causing a mismatch. Three-layer fix: - Invalidate cache when vault_path differs from current machine (CACHE_VERSION bump) - Exclude .laputa-cache.json via .git/info/exclude to prevent future commits - Defense-in-depth: match entries by relative path suffix in NoteList - Surface error message when modified files fetch fails Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/vault/cache.rs | 91 +++++++++++++++++++++++++++++++- src/App.tsx | 2 +- src/components/NoteList.test.tsx | 27 ++++++++++ src/components/NoteList.tsx | 34 ++++++++---- src/hooks/useVaultLoader.ts | 8 ++- 5 files changed, 147 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/vault/cache.rs b/src-tauri/src/vault/cache.rs index f6757949..a4ba87e1 100644 --- a/src-tauri/src/vault/cache.rs +++ b/src-tauri/src/vault/cache.rs @@ -7,12 +7,16 @@ use super::{parse_md_file, scan_vault, VaultEntry}; // --- Vault Cache --- /// Bump this when VaultEntry fields change to force a full rescan. -const CACHE_VERSION: u32 = 2; +const CACHE_VERSION: u32 = 3; #[derive(Debug, Serialize, Deserialize)] struct VaultCache { #[serde(default = "default_cache_version")] version: u32, + /// The vault path when the cache was written. Used to detect stale caches + /// from a different machine or a moved vault directory. + #[serde(default)] + vault_path: String, commit_hash: String, entries: Vec, } @@ -107,6 +111,7 @@ fn write_cache(vault: &Path, cache: &VaultCache) { if let Ok(data) = serde_json::to_string(cache) { let _ = fs::write(cache_path(vault), data); } + ensure_cache_excluded(vault); } /// Normalize an absolute path to a relative path for comparison with git output. @@ -135,6 +140,23 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec { .collect() } +/// Ensure `.laputa-cache.json` is excluded from git via `.git/info/exclude`. +/// This prevents the cache (which contains machine-specific absolute paths) +/// from being committed and causing stale-path bugs on cloned vaults. +fn ensure_cache_excluded(vault: &Path) { + let exclude_path = vault.join(".git/info/exclude"); + let entry = ".laputa-cache.json"; + if let Ok(content) = fs::read_to_string(&exclude_path) { + if content.lines().any(|line| line.trim() == entry) { + return; + } + let separator = if content.ends_with('\n') { "" } else { "\n" }; + let _ = fs::write(&exclude_path, format!("{content}{separator}{entry}\n")); + } else if exclude_path.parent().map(|p| p.is_dir()).unwrap_or(false) { + let _ = fs::write(&exclude_path, format!("{entry}\n")); + } +} + /// Sort entries by modified_at descending and write the cache. fn finalize_and_cache(vault: &Path, mut entries: Vec, hash: String) -> Vec { entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)); @@ -142,6 +164,7 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec, hash: String) vault, &VaultCache { version: CACHE_VERSION, + vault_path: vault.to_string_lossy().to_string(), commit_hash: hash, entries: entries.clone(), }, @@ -200,7 +223,10 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result, String> { }; if let Some(cache) = load_cache(vault_path) { - if cache.version != CACHE_VERSION { + let current_vault_str = vault_path.to_string_lossy(); + let cache_stale = cache.version != CACHE_VERSION + || (!cache.vault_path.is_empty() && cache.vault_path != current_vault_str.as_ref()); + if cache_stale { let entries = scan_vault(vault_path)?; return Ok(finalize_and_cache(vault_path, entries, current_hash)); } @@ -288,6 +314,67 @@ mod tests { assert_eq!(entries2[0].title, "Note"); } + #[test] + fn test_scan_vault_cached_invalidates_stale_vault_path() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + + // Init git repo + std::process::Command::new("git") + .args(["init"]) + .current_dir(vault) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(vault) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(vault) + .output() + .unwrap(); + + create_test_file(vault, "note.md", "# Note\n\nContent."); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(vault) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(vault) + .output() + .unwrap(); + + // Build cache normally + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + assert!( + entries[0].path.starts_with(&vault.to_string_lossy().as_ref()), + "Entry path should start with vault path" + ); + + // Tamper with cache to simulate a clone from a different machine + let cache_file = cache_path(vault); + let cache_data = fs::read_to_string(&cache_file).unwrap(); + let tampered = cache_data.replace( + &vault.to_string_lossy().as_ref(), + "/Users/other-machine/OtherVault", + ); + fs::write(&cache_file, tampered).unwrap(); + + // Rescanning should invalidate the stale cache and produce correct paths + let entries2 = scan_vault_cached(vault).unwrap(); + assert_eq!(entries2.len(), 1); + assert!( + entries2[0].path.starts_with(&vault.to_string_lossy().as_ref()), + "After stale-cache invalidation, paths should use the current vault path, got: {}", + entries2[0].path + ); + } + #[test] fn test_scan_vault_cached_incremental_different_commit() { let dir = TempDir::new().unwrap(); diff --git a/src/App.tsx b/src/App.tsx index b1dc40ba..046bc961 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -363,7 +363,7 @@ function App() { {noteListVisible && ( <>
- +
diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index 3bc4fd84..2b4e9727 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -996,6 +996,33 @@ describe('NoteList — virtual list with large datasets', () => { expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument() }) + it('matches entries by relative path suffix when absolute paths differ (cross-machine)', () => { + // Simulate a cloned vault where cached entries have paths from a different machine + const crossMachineEntries: VaultEntry[] = mockEntries.map((e) => ({ + ...e, + path: e.path.replace('/Users/luca/Laputa', '/Users/other-machine/OtherVault'), + })) + const modifiedFromCurrentMachine = [ + { path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const }, + { path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const }, + ] + render( + + ) + // Even though absolute paths differ, entries should match via relative path suffix + expect(screen.getByText('Build Laputa App')).toBeInTheDocument() + expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() + expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument() + }) + + it('shows error message when modifiedFilesError is set', () => { + render( + + ) + expect(screen.getByText(/Failed to load changes/)).toBeInTheDocument() + expect(screen.getByText(/git status failed/)).toBeInTheDocument() + }) + it('shows untracked (new) notes alongside modified notes in changes view', () => { const mixedFiles = [ { path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const }, diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 95159d14..471df6cc 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -26,6 +26,7 @@ interface NoteListProps { selectedNote: VaultEntry | null allContent: Record modifiedFiles?: ModifiedFile[] + modifiedFilesError?: string | null getNoteStatus?: (path: string) => NoteStatus sidebarCollapsed?: boolean onSelectNote: (entry: VaultEntry) => void @@ -143,13 +144,13 @@ function ListViewHeader({ isTrashView, expiredTrashCount }: { return } -function ListView({ isTrashView, isChangesView, expiredTrashCount, searched, query, renderItem, virtuosoRef }: { - isTrashView: boolean; isChangesView?: boolean; expiredTrashCount: number +function ListView({ isTrashView, isChangesView, changesError, expiredTrashCount, searched, query, renderItem, virtuosoRef }: { + isTrashView: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number searched: VaultEntry[]; query: string renderItem: (entry: VaultEntry) => React.ReactNode virtuosoRef?: React.RefObject }) { - const emptyText = isChangesView ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found') + const emptyText = (isChangesView && changesError) ? `Failed to load changes: ${changesError}` : isChangesView ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found') const hasHeader = isTrashView && expiredTrashCount > 0 if (searched.length === 0) { @@ -232,10 +233,15 @@ function toggleSetMember(set: Set, member: T): Set { interface NoteListDataParams { entries: VaultEntry[]; selection: SidebarSelection; allContent: Record query: string; listSort: SortOption; listDirection: SortDirection - modifiedPathSet: Set + modifiedPathSet: Set; modifiedSuffixes: string[] } -function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet }: NoteListDataParams) { +function isModifiedEntry(path: string, pathSet: Set, suffixes: string[]): boolean { + if (pathSet.has(path)) return true + return suffixes.some((suffix) => path.endsWith(suffix)) +} + +function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) { const isEntityView = selection.kind === 'entity' const isTrashView = selection.kind === 'filter' && selection.filter === 'trash' const isChangesView = selection.kind === 'filter' && selection.filter === 'changes' @@ -248,12 +254,12 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list const searched = useMemo(() => { if (isEntityView) return [] if (isChangesView) { - const sorted = [...entries.filter((e) => modifiedPathSet.has(e.path))].sort(getSortComparator(listSort, listDirection)) + const sorted = [...entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))].sort(getSortComparator(listSort, listDirection)) return filterByQuery(sorted, query) } const sorted = [...filterEntries(entries, selection)].sort(getSortComparator(listSort, listDirection)) return filterByQuery(sorted, query) - }, [entries, selection, isEntityView, isChangesView, listSort, listDirection, query, modifiedPathSet]) + }, [entries, selection, isEntityView, isChangesView, listSort, listDirection, query, modifiedPathSet, modifiedSuffixes]) const searchedGroups = useMemo(() => { if (!isEntityView) return [] @@ -273,7 +279,7 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list const defaultGetNoteStatus = (): NoteStatus => 'clean' -function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) { +function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) { const [search, setSearch] = useState('') const [searchVisible, setSearchVisible] = useState(false) const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) @@ -285,6 +291,14 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF [modifiedFiles], ) + // Suffix patterns for cross-machine robustness: if the vault cache carried + // stale absolute paths from another machine, fall back to matching by the + // relative path suffix so the changes view stays in sync with the badge. + const modifiedSuffixes = useMemo( + () => (modifiedFiles ?? []).map((f) => '/' + f.relativePath), + [modifiedFiles], + ) + const resolvedGetNoteStatus = useMemo<(path: string) => NoteStatus>( () => createNoteStatusResolver(getNoteStatus, modifiedFiles, modifiedPathSet), [getNoteStatus, modifiedFiles, modifiedPathSet], @@ -303,7 +317,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF const listConfig = sortPrefs['__list__'] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection } const listSort = listConfig.option const listDirection = listConfig.direction - const { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet }) + const { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }) const noteListKeyboard = useNoteListKeyboard({ items: searched, @@ -401,7 +415,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF {isEntityView && selection.kind === 'entity' ? ( ) : ( - + )} diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index 3c8abbc6..1f3da296 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -97,13 +97,14 @@ export function useVaultLoader(vaultPath: string) { const [entries, setEntries] = useState([]) const [allContent, setAllContent] = useState>({}) const [modifiedFiles, setModifiedFiles] = useState([]) + const [modifiedFilesError, setModifiedFilesError] = useState(null) const tracker = useNewNoteTracker() const pendingSave = usePendingSaveTracker() const unsaved = useUnsavedTracker() useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault - setEntries([]); setAllContent({}); setModifiedFiles([]); tracker.clear(); unsaved.clearAll() + setEntries([]); setAllContent({}); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll() loadVaultData(vaultPath) .then(({ entries: e, allContent: c }) => { setEntries(e); setAllContent(c) }) .catch((err) => console.warn('Vault scan failed:', err)) @@ -111,9 +112,12 @@ export function useVaultLoader(vaultPath: string) { const loadModifiedFiles = useCallback(async () => { try { + setModifiedFilesError(null) setModifiedFiles(await tauriCall('get_modified_files', { vaultPath }, {})) } catch (err) { + const message = typeof err === 'string' ? err : 'Failed to load changes' console.warn('Failed to load modified files:', err) + setModifiedFilesError(message) setModifiedFiles([]) } }, [vaultPath]) @@ -174,7 +178,7 @@ export function useVaultLoader(vaultPath: string) { ) return { - entries, allContent, modifiedFiles, + entries, allContent, modifiedFiles, modifiedFilesError, addEntry, updateEntry, removeEntry, replaceEntry, updateContent, loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit, getNoteStatus, commitAndPush, reloadVault,