diff --git a/src-tauri/src/commands/git.rs b/src-tauri/src/commands/git.rs index 5579fca8..b6b36e40 100644 --- a/src-tauri/src/commands/git.rs +++ b/src-tauri/src/commands/git.rs @@ -28,9 +28,20 @@ pub fn get_file_history( #[cfg(desktop)] #[tauri::command] -pub fn get_modified_files(vault_path: VaultPathArg) -> Result, String> { - let vault_path = expand_tilde(&vault_path); - crate::git::get_modified_files(&vault_path) +pub async fn get_modified_files( + vault_path: VaultPathArg, + include_stats: Option, +) -> Result, String> { + let vault_path = expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || { + if include_stats.unwrap_or(false) { + crate::git::get_modified_files_with_stats(&vault_path) + } else { + crate::git::get_modified_files(&vault_path) + } + }) + .await + .map_err(|e| format!("Task panicked: {e}"))? } #[cfg(desktop)] @@ -237,7 +248,10 @@ pub fn get_file_history( #[cfg(mobile)] #[tauri::command] -pub fn get_modified_files(_vault_path: VaultPathArg) -> Result, String> { +pub fn get_modified_files( + _vault_path: VaultPathArg, + _include_stats: Option, +) -> Result, String> { Ok(vec![]) } @@ -379,15 +393,15 @@ mod tests { (dir, vault) } - #[test] - fn desktop_git_commands_route_to_git_backend() { + #[tokio::test] + async fn desktop_git_commands_route_to_git_backend() { let (dir, vault) = create_initialized_vault(); let note = note_path(&dir, "note.md"); assert!(is_git_repo(vault.clone())); fs::write(dir.path().join("note.md"), "# Updated\n").unwrap(); - let modified = get_modified_files(vault.clone()).unwrap(); + let modified = get_modified_files(vault.clone(), None).await.unwrap(); assert!(modified.iter().any(|file| file.relative_path == "note.md")); let diff = get_file_diff(vault.clone(), note.clone()).unwrap(); diff --git a/src-tauri/src/commands/vault.rs b/src-tauri/src/commands/vault.rs index 8a27c3cd..ea26f4a0 100644 --- a/src-tauri/src/commands/vault.rs +++ b/src-tauri/src/commands/vault.rs @@ -166,9 +166,21 @@ mod tests { assert_eq!(err, ACTIVE_VAULT_PATH_ERROR); } - #[test] - fn test_save_note_content_rejects_traversal_outside_active_vault() { - assert_note_write_rejects_escape(save_note_content); + #[tokio::test] + async fn test_save_note_content_rejects_traversal_outside_active_vault() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path(); + let escape_path = vault_path.join("../outside.md"); + + let err = save_note_content( + escape_path, + "# Outside\n".to_string(), + vault_path_arg(vault_path), + ) + .await + .expect_err("expected traversal write to be rejected"); + + assert_eq!(err, ACTIVE_VAULT_PATH_ERROR); } #[test] diff --git a/src-tauri/src/commands/vault/file_cmds.rs b/src-tauri/src/commands/vault/file_cmds.rs index e9fed504..f585189e 100644 --- a/src-tauri/src/commands/vault/file_cmds.rs +++ b/src-tauri/src/commands/vault/file_cmds.rs @@ -144,14 +144,18 @@ pub fn validate_note_content( } #[tauri::command] -pub fn save_note_content( +pub async fn save_note_content( path: PathBuf, content: String, vault_path: Option, ) -> Result<(), String> { - with_writable_note_path(path, vault_path, |validated_path| { - vault::save_note_content(validated_path, &content) + tokio::task::spawn_blocking(move || { + with_writable_note_path(path, vault_path, |validated_path| { + vault::save_note_content(validated_path, &content) + }) }) + .await + .map_err(|e| format!("Task panicked: {e}"))? } #[tauri::command] @@ -293,8 +297,8 @@ mod tests { dir.path().join(name) } - #[test] - fn note_content_commands_roundtrip_with_requested_vault() { + #[tokio::test] + async fn note_content_commands_roundtrip_with_requested_vault() { let dir = TempDir::new().unwrap(); let root = vault_root(&dir); let note = note_path(&dir, "notes/command-note.md"); @@ -315,6 +319,7 @@ mod tests { "---\ntitle: Command Note\n---\n# Command Note\nBody\n".to_string(), Some(root.clone()), ) + .await .unwrap(); assert!(!sync_note_title(note.clone(), Some(root.clone())).unwrap()); @@ -323,6 +328,7 @@ mod tests { "# Updated Command Note\n".to_string(), Some(root.clone()), ) + .await .unwrap(); assert!(sync_note_title(note.clone(), Some(root.clone())).unwrap()); assert!(get_note_content(note, Some(root)) @@ -330,8 +336,8 @@ mod tests { .contains("title: Command Note")); } - #[test] - fn note_content_commands_accept_windows_sensitive_valid_segments() { + #[tokio::test] + async fn note_content_commands_accept_windows_sensitive_valid_segments() { let dir = TempDir::new().unwrap(); let root = vault_root(&dir); let note = root @@ -344,6 +350,7 @@ mod tests { "# Windows-Sensitive Path\n\nBody\n".to_string(), Some(root.clone()), ) + .await .unwrap(); assert_eq!( diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index 31e0b92c..9ebd5890 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -28,7 +28,9 @@ pub use remote::{ git_pull, git_push, git_remote_status, has_remote, GitPullResult, GitPushResult, GitRemoteStatus, }; -pub use status::{discard_file_changes, get_modified_files, ModifiedFile}; +pub use status::{ + discard_file_changes, get_modified_files, get_modified_files_with_stats, ModifiedFile, +}; use serde::Serialize; diff --git a/src-tauri/src/git/status.rs b/src-tauri/src/git/status.rs index 444ec368..a9b0e64c 100644 --- a/src-tauri/src/git/status.rs +++ b/src-tauri/src/git/status.rs @@ -138,7 +138,12 @@ fn resolve_diff_stats( relative_path: &Path, status: FileChangeStatus, diff_stats: &HashMap, + include_stats: bool, ) -> DiffStats { + if !include_stats { + return DiffStats::default(); + } + if status == FileChangeStatus::Untracked { return count_worktree_lines(vault, relative_path); } @@ -211,8 +216,18 @@ fn restore_tracked_file(vault: &Path, relative_path: &Path) -> Result<(), String } /// Get list of modified/added/deleted files in the vault (uncommitted changes). -pub fn get_modified_files(vault_path: &str) -> Result, String> { - let vault = Path::new(vault_path); +pub fn get_modified_files(vault_path: impl AsRef) -> Result, String> { + get_modified_files_impl(vault_path.as_ref(), false) +} + +/// Get list of modified/added/deleted files with line-level diff statistics. +pub fn get_modified_files_with_stats( + vault_path: impl AsRef, +) -> Result, String> { + get_modified_files_impl(vault_path.as_ref(), true) +} + +fn get_modified_files_impl(vault: &Path, include_stats: bool) -> Result, String> { let output = git_command() .args(["status", "--porcelain", "--untracked-files=all"]) .current_dir(vault) @@ -224,7 +239,11 @@ pub fn get_modified_files(vault_path: &str) -> Result, String> return Err(format!("git status failed: {}", stderr.trim())); } - let diff_stats = load_diff_stats(vault)?; + let diff_stats = if include_stats { + load_diff_stats(vault)? + } else { + HashMap::new() + }; let stdout = String::from_utf8_lossy(&output.stdout); let files = stdout .lines() @@ -243,7 +262,13 @@ pub fn get_modified_files(vault_path: &str) -> Result, String> let status = FileChangeStatus::from_code(status_code); let full_path = vault.join(&relative_path).to_string_lossy().to_string(); - let stats = resolve_diff_stats(vault, Path::new(&relative_path), status, &diff_stats); + let stats = resolve_diff_stats( + vault, + Path::new(&relative_path), + status, + &diff_stats, + include_stats, + ); Some(ModifiedFile { path: full_path, @@ -309,7 +334,7 @@ mod tests { } #[test] - fn test_get_modified_files() { + fn test_get_modified_files_with_stats() { let dir = setup_git_repo(); let vault = dir.path(); @@ -331,7 +356,7 @@ mod tests { // Add an untracked file fs::write(vault.join("new.md"), "# New\n").unwrap(); - let modified = get_modified_files(vault.to_str().unwrap()).unwrap(); + let modified = get_modified_files_with_stats(vault.to_str().unwrap()).unwrap(); assert!(modified.len() >= 2); let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect(); @@ -353,6 +378,34 @@ mod tests { assert_eq!(untracked_entry.deleted_lines, None); } + #[test] + fn test_get_modified_files_omits_stats_by_default() { + let dir = setup_git_repo(); + let vault = dir.path(); + + fs::write(vault.join("note.md"), "# Note\n").unwrap(); + git_command() + .args(["add", "note.md"]) + .current_dir(vault) + .output() + .unwrap(); + git_command() + .args(["commit", "-m", "Add note"]) + .current_dir(vault) + .output() + .unwrap(); + + fs::write(vault.join("note.md"), "# Note\n\nUpdated.").unwrap(); + fs::write(vault.join("new.md"), "# New\n").unwrap(); + + let modified = get_modified_files(vault.to_str().unwrap()).unwrap(); + + assert!(modified.len() >= 2); + assert!(modified.iter().all(|file| file.added_lines.is_none() + && file.deleted_lines.is_none() + && !file.binary)); + } + #[test] fn test_get_modified_files_untracked_in_subdirectory() { let dir = setup_git_repo(); @@ -375,7 +428,7 @@ mod tests { fs::create_dir_all(vault.join("note")).unwrap(); fs::write(vault.join("note/brand-new.md"), "# Brand New\n").unwrap(); - let modified = get_modified_files(vault.to_str().unwrap()).unwrap(); + let modified = get_modified_files_with_stats(vault.to_str().unwrap()).unwrap(); assert_eq!(modified.len(), 1); assert_eq!(modified[0].status, "untracked"); @@ -399,7 +452,7 @@ mod tests { write_and_commit_markdown(vault, vp, relative_path, "# 初始\n"); fs::write(vault.join(relative_path), "# 初始\n\n更新\n").unwrap(); - let modified = get_modified_files(vp).unwrap(); + let modified = get_modified_files_with_stats(vp).unwrap(); let file = modified .iter() .find(|file| file.relative_path == relative_path) diff --git a/src/App.tsx b/src/App.tsx index a7c903fa..678bc633 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -79,7 +79,7 @@ import { DeleteProgressNotice } from './components/DeleteProgressNotice' import { UpdateBanner } from './components/UpdateBanner' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from './mock-tauri' -import type { SidebarSelection, InboxPeriod, VaultEntry, ViewDefinition, WorkspaceIdentity } from './types' +import type { SidebarSelection, InboxPeriod, ModifiedFile, VaultEntry, ViewDefinition, WorkspaceIdentity } from './types' import type { NoteListItem } from './utils/ai-context' import { initializeNoteProperties } from './utils/initializeNoteProperties' import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers' @@ -146,6 +146,24 @@ declare global { const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION +function modifiedFileKey(file: ModifiedFile): string { + return `${file.vaultPath ?? ''}\0${file.relativePath}\0${file.status}` +} + +function activeVaultModifiedFiles(files: ModifiedFile[], vaultPath: string): ModifiedFile[] { + return files.map((file) => ({ ...file, vaultPath: file.vaultPath ?? vaultPath })) +} + +function mergeModifiedFiles(...groups: ModifiedFile[][]): ModifiedFile[] { + const byKey = new Map() + for (const group of groups) { + for (const file of group) { + byKey.set(modifiedFileKey(file), file) + } + } + return [...byKey.values()] +} + function shouldPreferOnboardingVaultPath( onboardingState: { status: string; vaultPath?: string }, vaults: Array<{ path: string }>, @@ -352,6 +370,7 @@ function App() { const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath) const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization) const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization) + const isChangesSelection = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'changes' useSelectionSanitizer({ effectiveSelection, @@ -418,9 +437,9 @@ function App() { const refreshGitModifiedFiles = useCallback(async () => { await Promise.all([ loadDefaultVaultModifiedFiles(), - loadAllGitModifiedFiles(), + loadAllGitModifiedFiles({ includeStats: isChangesSelection }), ]) - }, [loadAllGitModifiedFiles, loadDefaultVaultModifiedFiles]) + }, [isChangesSelection, loadAllGitModifiedFiles, loadDefaultVaultModifiedFiles]) const loadVaultModifiedFiles = refreshGitModifiedFiles useEffect(() => { @@ -852,11 +871,17 @@ function App() { openNoteInNewWindow(entry.path, vaultPathForEntry(entry, resolvedPath), entry.title) }, [resolvedPath]) - const allGitModifiedFiles = gitSurfaces.allModifiedFiles + const allGitModifiedFiles = useMemo( + () => mergeModifiedFiles( + gitSurfaces.allModifiedFiles, + activeVaultModifiedFiles(vault.modifiedFiles, resolvedPath), + ), + [gitSurfaces.allModifiedFiles, resolvedPath, vault.modifiedFiles], + ) const selectedChangesModifiedFiles = gitSurfaces.changesModifiedFiles const commitModifiedFiles = gitSurfaces.commitModifiedFiles const changesRepositoryPath = gitSurfaces.changesRepositoryPath - const gitModifiedCount = gitSurfaces.totalModifiedCount + const gitModifiedCount = allGitModifiedFiles.length const { activeDeletedFile, @@ -925,6 +950,11 @@ function App() { const handleAppSave = appSave.handleSave const loadModifiedFiles = refreshGitModifiedFiles + useEffect(() => { + if (!isChangesSelection) return + void loadModifiedFilesForRepository(changesRepositoryPath, { includeStats: true }) + }, [changesRepositoryPath, isChangesSelection, loadModifiedFilesForRepository]) + useEffect(() => { if (modifiedFilesSignature.length === 0) return recordAutoGitActivity() @@ -953,12 +983,15 @@ function App() { ? notes.tabs.find((tab) => tab.entry.path === notes.activeTabPath) : null if (activeTab) { - await loadModifiedFilesForRepository(vaultPathForEntry(activeTab.entry, resolvedPath)) + await loadModifiedFilesForRepository(vaultPathForEntry(activeTab.entry, resolvedPath), { + includeStats: isChangesSelection, + }) } recordAutoGitActivity() return result }, [ handleAppSave, + isChangesSelection, loadModifiedFilesForRepository, notes.activeTabPath, notes.tabs, @@ -1575,8 +1608,7 @@ function App() { ) } - const isChangesSelection = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'changes' - const noteListModifiedFiles = isChangesSelection ? selectedChangesModifiedFiles : allGitModifiedFiles + const noteListModifiedFiles = isChangesSelection ? selectedChangesModifiedFiles : undefined const noteListModifiedFilesError = isChangesSelection ? gitSurfaces.changesModifiedFilesError : null return ( diff --git a/src/hooks/useEditorTabSwap.performance.test.ts b/src/hooks/useEditorTabSwap.performance.test.ts new file mode 100644 index 00000000..4a219033 --- /dev/null +++ b/src/hooks/useEditorTabSwap.performance.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { useEditorTabSwap } from './useEditorTabSwap' +import type { VaultEntry } from '../types' + +const initialBlocks = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }] + +function makeTab(path: string, title: string) { + return { + entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as VaultEntry, + content: `---\ntitle: ${title}\n---\n\n# ${title}\n\nBody of ${title}.`, + } +} + +function makeMockEditor(docRef: { current: unknown[] }) { + const editor = { + get document() { return docRef.current }, + get prosemirrorView() { return {} }, + onMount: (cb: () => void) => { cb(); return () => {} }, + replaceBlocks: vi.fn((_old, newBlocks) => { docRef.current = newBlocks }), + blocksToMarkdownLossy: vi.fn(() => ''), + tryParseMarkdownToBlocks: vi.fn(() => initialBlocks), + } + return editor +} + +function installEditorDomSpies() { + vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0) + return 0 + }) +} + +async function flushEditorTick() { + await act(() => new Promise((resolve) => setTimeout(resolve, 0))) +} + +describe('useEditorTabSwap rich-editor serialization performance', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('does not reserialize when local rich-editor content catches up to tab state', async () => { + installEditorDomSpies() + const tab = makeTab('a.md', 'Note A') + const onContentChange = vi.fn() + const docRef = { current: initialBlocks as unknown[] } + const editor = makeMockEditor(docRef) + let currentTabs = [tab] + + const { result, rerender } = renderHook( + ({ tabs }) => useEditorTabSwap({ + tabs, + activeTabPath: 'a.md', + rawMode: false, + editor: editor as never, + onContentChange, + }), + { initialProps: { tabs: currentTabs } }, + ) + await flushEditorTick() + + docRef.current = [{ + type: 'paragraph', + content: [{ type: 'text', text: 'Changed body', styles: {} }], + children: [], + }] + editor.blocksToMarkdownLossy.mockReturnValue('Changed body\n') + editor.blocksToMarkdownLossy.mockClear() + + act(() => { + result.current.handleEditorChange() + result.current.flushPendingEditorChange() + }) + + expect(editor.blocksToMarkdownLossy).toHaveBeenCalledTimes(1) + const nextContent = onContentChange.mock.calls[0][1] as string + currentTabs = [{ ...tab, content: nextContent }] + rerender({ tabs: currentTabs }) + await flushEditorTick() + + expect(editor.blocksToMarkdownLossy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index 24b7a27e..a5771dc0 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -174,6 +174,42 @@ function isUntitledRenameTransition( }) } +function activeEditorChangePath(options: { + prevActivePathRef: MutableRefObject + editorContentPathRef: EditorContentPathRef +}): string | null { + const { prevActivePathRef, editorContentPathRef } = options + const path = prevActivePathRef.current + if (!path || editorContentPathRef.current !== path) return null + return path +} + +function previousContentForPath(options: { + path: string + tabs: Tab[] + cache: Map +}): string | undefined { + const { path, tabs, cache } = options + return tabs.find(t => t.entry.path === path)?.content ?? cache.get(path)?.sourceContent +} + +function serializedEditorChange(options: { + editor: ReturnType + path: string + previousContent: string + vaultPath?: string +}): { blocks: CachedTabState['blocks'], content: string } | null { + const { editor, path, previousContent, vaultPath } = options + const blocks = editor.document + const rawBodyMarkdown = trySerializeEditorBody(editor, 'editor change') + if (rawBodyMarkdown === null) return null + const bodyMarkdown = vaultPath + ? portableImageUrls(rawBodyMarkdown, vaultPath, path) + : rawBodyMarkdown + const [frontmatter] = splitFrontmatter(previousContent) + return { blocks, content: `${frontmatter}${bodyMarkdown}` } +} + function useEditorChangeHandler(options: { editor: ReturnType tabsRef: MutableRefObject @@ -198,29 +234,31 @@ function useEditorChangeHandler(options: { } = options const propagateEditorChange = useCallback(() => { - const path = prevActivePathRef.current + const path = activeEditorChangePath({ prevActivePathRef, editorContentPathRef }) if (!path) return - if (editorContentPathRef.current !== path) return - const tab = tabsRef.current.find(t => t.entry.path === path) - const previousContent = tab?.content ?? tabCacheRef.current.get(path)?.sourceContent + const previousContent = previousContentForPath({ + path, + tabs: tabsRef.current, + cache: tabCacheRef.current, + }) if (!previousContent) return - const blocks = editor.document - const rawBodyMarkdown = trySerializeEditorBody(editor, 'editor change') - if (rawBodyMarkdown === null) return - const bodyMarkdown = vaultPathRef.current - ? portableImageUrls(rawBodyMarkdown, vaultPathRef.current, path) - : rawBodyMarkdown - const [frontmatter] = splitFrontmatter(previousContent) - const nextContent = `${frontmatter}${bodyMarkdown}` - pendingLocalContentRef.current = { path, content: nextContent } + const next = serializedEditorChange({ + editor, + path, + previousContent, + vaultPath: vaultPathRef.current, + }) + if (!next) return + + pendingLocalContentRef.current = { path, content: next.content } cacheResolvedEditorState(tabCacheRef.current, path, { - blocks, + blocks: next.blocks, scrollTop: readEditorScrollTop(), - sourceContent: nextContent, + sourceContent: next.content, }, vaultPathRef.current) - onContentChangeRef.current?.(path, nextContent) + onContentChangeRef.current?.(path, next.content) }, [editor, editorContentPathRef, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, tabCacheRef, tabsRef, vaultPathRef]) return useDebouncedEditorChange({ onFlush: propagateEditorChange, suppressChangeRef }) @@ -447,8 +485,8 @@ function handleStableActivePath(options: { if (rawModeJustEnded) { return !markRawModeReswapPending({ activeTabPath, cache, rawSwapPendingRef }) } - if (currentEditorMatchesActiveTab({ activeTabPath, activeTab, editor, editorMountedRef })) { - return cacheStableActiveTabAndClearPending({ + if (shouldKeepPendingLocalContent({ activeTabPath, activeTab, pendingLocalContentRef })) { + return consumePendingLocalContent({ cache, activeTabPath, activeTab, @@ -458,8 +496,8 @@ function handleStableActivePath(options: { pendingLocalContentRef, }) } - if (shouldKeepPendingLocalContent({ activeTabPath, activeTab, pendingLocalContentRef })) { - return consumePendingLocalContent({ + if (currentEditorMatchesActiveTab({ activeTabPath, activeTab, editor, editorMountedRef })) { + return cacheStableActiveTabAndClearPending({ cache, activeTabPath, activeTab, diff --git a/src/hooks/useGitFileWorkflows.ts b/src/hooks/useGitFileWorkflows.ts index 9e740fbc..bab04910 100644 --- a/src/hooks/useGitFileWorkflows.ts +++ b/src/hooks/useGitFileWorkflows.ts @@ -20,7 +20,7 @@ interface GitFileWorkflowParams { effectiveSelection: SidebarSelection entriesByPath: Map historyRepositoryPath: string - loadModifiedFilesForRepository: (vaultPath: string) => Promise + loadModifiedFilesForRepository: (vaultPath: string, options?: { includeStats?: boolean }) => Promise onCloseAllTabs: () => void onOpenTabWithContent: (entry: DeletedNoteEntry, content: string) => void onReplaceActiveTab: (entry: VaultEntry) => Promise | unknown @@ -271,7 +271,7 @@ function useDiscardFileAction({ const targetFile = selectedChangesModifiedFiles.find((file) => file.relativePath === relativePath) try { await appTauriCall('git_discard_file', { vaultPath: changesRepositoryPath, relativePath }) - await loadModifiedFilesForRepository(changesRepositoryPath) + await loadModifiedFilesForRepository(changesRepositoryPath, { includeStats: true }) await syncActiveTabAfterDiscard({ activePathBefore: activeTabPath, onCloseAllTabs, diff --git a/src/hooks/useGitRepositories.test.ts b/src/hooks/useGitRepositories.test.ts index d6f775b3..7d19eb0e 100644 --- a/src/hooks/useGitRepositories.test.ts +++ b/src/hooks/useGitRepositories.test.ts @@ -91,7 +91,7 @@ describe('useGitRepositories', () => { repositories, })) - await waitFor(() => expect(mockInvoke).toHaveBeenCalledWith('get_modified_files', { vaultPath: '/default' })) + await waitFor(() => expect(mockInvoke).toHaveBeenCalledWith('get_modified_files', { vaultPath: '/default', includeStats: false })) await act(async () => { await result.current.refreshRemoteStatusForRepository('/work') @@ -100,4 +100,22 @@ describe('useGitRepositories', () => { expect(mockInvoke).toHaveBeenCalledWith('git_remote_status', { vaultPath: '/work' }) expect(result.current.remoteStatusForRepository('/work')).toEqual(remoteStatus(false)) }) + + it('requests line stats only when explicitly requested', async () => { + const repositories = [{ path: '/default', label: 'Default', defaultForNewNotes: true }] + vi.mocked(mockInvoke).mockResolvedValue([]) + + const { result } = renderHook(() => useGitRepositories({ + defaultVaultPath: '/default', + repositories, + })) + + await waitFor(() => expect(mockInvoke).toHaveBeenCalledWith('get_modified_files', { vaultPath: '/default', includeStats: false })) + + await act(async () => { + await result.current.loadModifiedFilesForRepository('/default', { includeStats: true }) + }) + + expect(mockInvoke).toHaveBeenCalledWith('get_modified_files', { vaultPath: '/default', includeStats: true }) + }) }) diff --git a/src/hooks/useGitRepositories.ts b/src/hooks/useGitRepositories.ts index b5a3a828..d67cbd2d 100644 --- a/src/hooks/useGitRepositories.ts +++ b/src/hooks/useGitRepositories.ts @@ -45,6 +45,10 @@ interface RepositoryFilesArgs { vaultPath: string } +export interface LoadModifiedFilesOptions { + includeStats?: boolean +} + const EMPTY_MODIFIED_FILES: RepositoryModifiedFiles = { error: null, files: [], @@ -101,13 +105,17 @@ function useRepositoryModifiedFiles(repositories: GitRepositoryOption[]) { const [byRepository, setByRepository] = useState>(() => new Map()) const loadIdsRef = useRef(new Map()) - const loadModifiedFilesForRepository = useCallback(async (vaultPath: string) => { + const loadModifiedFilesForRepository = useCallback(async ( + vaultPath: string, + options: LoadModifiedFilesOptions = {}, + ) => { if (!vaultPath.trim()) return [] as ModifiedFile[] const loadId = nextRepositoryLoadId({ loadIds: loadIdsRef.current, path: vaultPath }) + const includeStats = options.includeStats === true try { const files = withRepositoryPath({ - files: await tauriCall('get_modified_files', { vaultPath }), + files: await tauriCall('get_modified_files', { vaultPath, includeStats }), vaultPath, }) if (isLatestRepositoryLoad({ loadIds: loadIdsRef.current, path: vaultPath, id: loadId })) { @@ -123,8 +131,8 @@ function useRepositoryModifiedFiles(repositories: GitRepositoryOption[]) { } }, []) - const loadAllModifiedFiles = useCallback(async () => { - await Promise.all(repositories.map((repository) => loadModifiedFilesForRepository(repository.path))) + const loadAllModifiedFiles = useCallback(async (options: LoadModifiedFilesOptions = {}) => { + await Promise.all(repositories.map((repository) => loadModifiedFilesForRepository(repository.path, options))) }, [loadModifiedFilesForRepository, repositories]) useEffect(() => { diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index 981a715a..62b926a5 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -439,7 +439,7 @@ function useModifiedFilesLoader(vaultPath: string, isCurrentVaultPath: (path: st try { const files = await tauriCall({ command: 'get_modified_files', - tauriArgs: { vaultPath: path }, + tauriArgs: { vaultPath: path, includeStats: false }, mockArgs: {}, }) if (isCurrentVaultPath(path)) setModifiedFiles(files)