2026-02-26 11:41:46 +01:00
|
|
|
import { useCallback, useEffect, useState, startTransition } from 'react'
|
2026-02-17 12:10:21 +01:00
|
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
|
|
|
import { isTauri, mockInvoke } from '../mock-tauri'
|
2026-04-02 16:09:43 +02:00
|
|
|
import type { VaultEntry, FolderNode, GitCommit, ModifiedFile, NoteStatus, GitPushResult, ViewFile } from '../types'
|
2026-03-09 13:05:18 +01:00
|
|
|
import { clearPrefetchCache } from './useTabManagement'
|
2026-02-17 12:10:21 +01:00
|
|
|
|
2026-02-22 10:55:45 +01:00
|
|
|
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
|
|
|
|
|
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadVaultData(vaultPath: string) {
|
|
|
|
|
if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing')
|
|
|
|
|
const entries = await tauriCall<VaultEntry[]>('list_vault', { path: vaultPath })
|
|
|
|
|
console.log(`Vault scan complete: ${entries.length} entries found`)
|
2026-03-08 22:15:08 +01:00
|
|
|
return { entries }
|
2026-02-22 10:55:45 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-19 09:51:06 +01:00
|
|
|
async function commitWithPush(vaultPath: string, message: string): Promise<GitPushResult> {
|
2026-02-22 10:55:45 +01:00
|
|
|
if (!isTauri()) {
|
|
|
|
|
await mockInvoke<string>('git_commit', { message })
|
2026-03-19 09:51:06 +01:00
|
|
|
return mockInvoke<GitPushResult>('git_push', {})
|
2026-02-22 10:55:45 +01:00
|
|
|
}
|
|
|
|
|
await invoke<string>('git_commit', { vaultPath, message })
|
2026-03-19 09:51:06 +01:00
|
|
|
return invoke<GitPushResult>('git_push', { vaultPath })
|
2026-02-22 10:55:45 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-24 15:54:24 +01:00
|
|
|
function useNewNoteTracker() {
|
|
|
|
|
const [newPaths, setNewPaths] = useState<Set<string>>(new Set())
|
|
|
|
|
|
|
|
|
|
const trackNew = useCallback((path: string) => {
|
|
|
|
|
setNewPaths((prev) => new Set(prev).add(path))
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const clear = useCallback(() => setNewPaths(new Set()), [])
|
|
|
|
|
|
2026-02-24 22:20:22 +01:00
|
|
|
return { newPaths, trackNew, clear }
|
2026-02-24 15:54:24 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-27 18:17:47 +01:00
|
|
|
function useUnsavedTracker() {
|
|
|
|
|
const [unsavedPaths, setUnsavedPaths] = useState<Set<string>>(new Set())
|
|
|
|
|
|
|
|
|
|
const trackUnsaved = useCallback((path: string) => {
|
|
|
|
|
setUnsavedPaths((prev) => new Set(prev).add(path))
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const clearUnsaved = useCallback((path: string) => {
|
|
|
|
|
setUnsavedPaths((prev) => {
|
|
|
|
|
const next = new Set(prev)
|
|
|
|
|
next.delete(path)
|
|
|
|
|
return next
|
|
|
|
|
})
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const clearAll = useCallback(() => setUnsavedPaths(new Set()), [])
|
|
|
|
|
|
|
|
|
|
return { unsavedPaths, trackUnsaved, clearUnsaved, clearAll }
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 14:11:31 +01:00
|
|
|
function usePendingSaveTracker() {
|
|
|
|
|
const [pendingSavePaths, setPendingSavePaths] = useState<Set<string>>(new Set())
|
|
|
|
|
|
|
|
|
|
const addPendingSave = useCallback((path: string) => {
|
|
|
|
|
setPendingSavePaths((prev) => new Set(prev).add(path))
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const removePendingSave = useCallback((path: string) => {
|
|
|
|
|
setPendingSavePaths((prev) => {
|
|
|
|
|
const next = new Set(prev)
|
|
|
|
|
next.delete(path)
|
|
|
|
|
return next
|
|
|
|
|
})
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
return { pendingSavePaths, addPendingSave, removePendingSave }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resolveNoteStatus(
|
2026-02-27 18:17:47 +01:00
|
|
|
path: string, newPaths: Set<string>, modifiedFiles: ModifiedFile[], pendingSavePaths?: Set<string>, unsavedPaths?: Set<string>,
|
2026-02-27 14:11:31 +01:00
|
|
|
): NoteStatus {
|
2026-02-27 18:17:47 +01:00
|
|
|
if (unsavedPaths?.has(path)) return 'unsaved'
|
2026-02-27 14:11:31 +01:00
|
|
|
if (pendingSavePaths?.has(path)) return 'pendingSave'
|
2026-02-24 15:54:24 +01:00
|
|
|
if (newPaths.has(path)) return 'new'
|
2026-02-24 22:20:22 +01:00
|
|
|
const gitEntry = modifiedFiles.find((f) => f.path === path)
|
|
|
|
|
if (!gitEntry) return 'clean'
|
|
|
|
|
if (gitEntry.status === 'untracked' || gitEntry.status === 'added') return 'new'
|
2026-04-07 20:09:04 +02:00
|
|
|
if (gitEntry.status === 'modified' || gitEntry.status === 'deleted') return 'modified'
|
2026-02-24 15:54:24 +01:00
|
|
|
return 'clean'
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 17:45:31 +01:00
|
|
|
export function useVaultLoader(vaultPath: string) {
|
2026-02-17 12:10:21 +01:00
|
|
|
const [entries, setEntries] = useState<VaultEntry[]>([])
|
2026-03-31 11:14:50 +02:00
|
|
|
const [folders, setFolders] = useState<FolderNode[]>([])
|
2026-04-02 16:09:43 +02:00
|
|
|
const [views, setViews] = useState<ViewFile[]>([])
|
2026-02-17 12:10:21 +01:00
|
|
|
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
|
2026-03-02 23:25:54 +01:00
|
|
|
const [modifiedFilesError, setModifiedFilesError] = useState<string | null>(null)
|
2026-02-24 15:54:24 +01:00
|
|
|
const tracker = useNewNoteTracker()
|
2026-02-27 14:11:31 +01:00
|
|
|
const pendingSave = usePendingSaveTracker()
|
2026-02-27 18:17:47 +01:00
|
|
|
const unsaved = useUnsavedTracker()
|
2026-02-17 12:10:21 +01:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
fix: resolve React hooks/compiler ESLint errors
- Wrap ref assignments in useEffect to fix refs-during-render in
useTabManagement, useKeyboardNavigation, and Editor
- Rewrite useAppKeyboard to define keyMap inside useEffect, eliminating
ref access during render
- Add eslint-disable for react-hooks/set-state-in-effect on legitimate
dialog reset patterns (CommitDialog, CreateNoteDialog, CreateTypeDialog,
QuickOpenPalette, AIChatPanel, useVaultLoader)
- Add eslint-disable for react-hooks/static-components on icon lookups
(NoteItem, NoteList PinnedCard) — stateless icon components from a
static map
- Add eslint-disable for react-hooks/purity on Date.now() in
NoteItem TrashDateLine — intentionally memoized on trashedAt
- Remove unused `model` param from buildSystemPrompt (ai-chat.ts) and
update callers
- Fix exhaustive-deps: suppress vault object dep in App.tsx git history
effect (vault object is unstable, loadGitHistory is the real dep)
- Remove unused `modifiedFiles` from useNoteListData params and caller
- Add missing `ref` to Sidebar useOutsideClick deps
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:06:51 +01:00
|
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
|
2026-04-02 16:09:43 +02:00
|
|
|
setEntries([]); setFolders([]); setViews([]); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll()
|
2026-02-22 10:55:45 +01:00
|
|
|
loadVaultData(vaultPath)
|
2026-03-08 22:15:08 +01:00
|
|
|
.then(({ entries: e }) => { setEntries(e) })
|
2026-02-22 10:55:45 +01:00
|
|
|
.catch((err) => console.warn('Vault scan failed:', err))
|
2026-03-31 11:14:50 +02:00
|
|
|
tauriCall<FolderNode[]>('list_vault_folders', { path: vaultPath })
|
|
|
|
|
.then((f) => { setFolders(f ?? []) })
|
|
|
|
|
.catch(() => { /* folders are optional — ignore errors */ })
|
2026-04-02 16:09:43 +02:00
|
|
|
tauriCall<ViewFile[]>('list_views', { vaultPath })
|
|
|
|
|
.then((v) => { setViews(v ?? []) })
|
|
|
|
|
.catch(() => { /* views are optional — ignore errors */ })
|
2026-02-24 15:54:24 +01:00
|
|
|
}, [vaultPath]) // eslint-disable-line react-hooks/exhaustive-deps -- tracker.clear is stable
|
2026-02-17 12:10:21 +01:00
|
|
|
|
|
|
|
|
const loadModifiedFiles = useCallback(async () => {
|
|
|
|
|
try {
|
2026-03-02 23:25:54 +01:00
|
|
|
setModifiedFilesError(null)
|
2026-02-22 10:55:45 +01:00
|
|
|
setModifiedFiles(await tauriCall<ModifiedFile[]>('get_modified_files', { vaultPath }, {}))
|
2026-02-17 12:10:21 +01:00
|
|
|
} catch (err) {
|
2026-03-02 23:25:54 +01:00
|
|
|
const message = typeof err === 'string' ? err : 'Failed to load changes'
|
2026-02-17 12:10:21 +01:00
|
|
|
console.warn('Failed to load modified files:', err)
|
2026-03-02 23:25:54 +01:00
|
|
|
setModifiedFilesError(message)
|
2026-02-17 12:10:21 +01:00
|
|
|
setModifiedFiles([])
|
|
|
|
|
}
|
2026-02-17 17:45:31 +01:00
|
|
|
}, [vaultPath])
|
2026-02-17 12:10:21 +01:00
|
|
|
|
fix: resolve React hooks/compiler ESLint errors
- Wrap ref assignments in useEffect to fix refs-during-render in
useTabManagement, useKeyboardNavigation, and Editor
- Rewrite useAppKeyboard to define keyMap inside useEffect, eliminating
ref access during render
- Add eslint-disable for react-hooks/set-state-in-effect on legitimate
dialog reset patterns (CommitDialog, CreateNoteDialog, CreateTypeDialog,
QuickOpenPalette, AIChatPanel, useVaultLoader)
- Add eslint-disable for react-hooks/static-components on icon lookups
(NoteItem, NoteList PinnedCard) — stateless icon components from a
static map
- Add eslint-disable for react-hooks/purity on Date.now() in
NoteItem TrashDateLine — intentionally memoized on trashedAt
- Remove unused `model` param from buildSystemPrompt (ai-chat.ts) and
update callers
- Fix exhaustive-deps: suppress vault object dep in App.tsx git history
effect (vault object is unstable, loadGitHistory is the real dep)
- Remove unused `modifiedFiles` from useNoteListData params and caller
- Add missing `ref` to Sidebar useOutsideClick deps
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:06:51 +01:00
|
|
|
useEffect(() => { loadModifiedFiles() }, [loadModifiedFiles]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load
|
2026-02-17 12:10:21 +01:00
|
|
|
|
2026-02-26 11:41:46 +01:00
|
|
|
// PERF: startTransition defers the expensive entries update (filter/sort on
|
|
|
|
|
// 9000+ entries) so the high-priority tab render completes in <50ms first.
|
2026-03-08 22:15:08 +01:00
|
|
|
const addEntry = useCallback((entry: VaultEntry) => {
|
2026-02-26 11:41:46 +01:00
|
|
|
startTransition(() => {
|
|
|
|
|
setEntries((prev) => {
|
|
|
|
|
if (prev.some(e => e.path === entry.path)) return prev
|
|
|
|
|
return [entry, ...prev]
|
|
|
|
|
})
|
|
|
|
|
tracker.trackNew(entry.path)
|
2026-02-24 23:03:01 +01:00
|
|
|
})
|
2026-02-24 15:54:24 +01:00
|
|
|
}, [tracker])
|
2026-02-17 12:10:21 +01:00
|
|
|
|
2026-04-01 10:33:40 +02:00
|
|
|
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) => {
|
|
|
|
|
setEntries((prev) => {
|
|
|
|
|
let changed = false
|
|
|
|
|
const next = prev.map((e) => {
|
|
|
|
|
if (e.path === path) { changed = true; return { ...e, ...patch } }
|
|
|
|
|
return e
|
|
|
|
|
})
|
|
|
|
|
return changed ? next : prev
|
|
|
|
|
})
|
|
|
|
|
}, [])
|
2026-02-21 13:27:33 +01:00
|
|
|
|
feat: add optimistic error recovery for note creation (#69)
Note creation was already optimistic (UI updates before disk write) but
errors were silently swallowed. Now if the disk write fails, the
optimistic entry is reverted (tab closed, entry removed) and the user
sees an error toast. Each note creation is independent, so one failure
in rapid Cmd+N presses doesn't affect the others.
- Add removeEntry to useVaultLoader for reverting optimistic adds
- Refactor persistNewNote to return a Promise for error handling
- Extract navigateWikilink, persistOptimistic, createAndPersist,
and runFrontmatterAndApply helpers to reduce hook complexity
- Add tests for error recovery: single note, type, success path,
and rapid creation with partial failure
- Code health: 8.93→9.01 (CC fixed: 10→7, primitive obsession improved)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:11:59 +01:00
|
|
|
const removeEntry = useCallback((path: string) => {
|
|
|
|
|
setEntries((prev) => prev.filter((e) => e.path !== path))
|
|
|
|
|
}, [])
|
|
|
|
|
|
2026-03-08 22:15:08 +01:00
|
|
|
const replaceEntry = useCallback((oldPath: string, patch: Partial<VaultEntry> & { path: string }) => {
|
2026-02-21 19:22:44 +01:00
|
|
|
setEntries((prev) => prev.map((e) => e.path === oldPath ? { ...e, ...patch } : e))
|
|
|
|
|
}, [])
|
|
|
|
|
|
2026-02-17 12:10:21 +01:00
|
|
|
const loadGitHistory = useCallback(async (path: string): Promise<GitCommit[]> => {
|
2026-02-22 10:55:45 +01:00
|
|
|
try { return await tauriCall<GitCommit[]>('get_file_history', { vaultPath, path }, { path }) }
|
|
|
|
|
catch (err) { console.warn('Failed to load git history:', err); return [] }
|
2026-02-17 17:45:31 +01:00
|
|
|
}, [vaultPath])
|
2026-02-17 12:10:21 +01:00
|
|
|
|
2026-02-22 10:55:45 +01:00
|
|
|
const loadDiffAtCommit = useCallback((path: string, commitHash: string): Promise<string> =>
|
2026-02-24 15:54:24 +01:00
|
|
|
tauriCall<string>('get_file_diff_at_commit', { vaultPath, path, commitHash }, { path, commitHash }), [vaultPath])
|
2026-02-21 22:27:18 +01:00
|
|
|
|
2026-02-22 10:55:45 +01:00
|
|
|
const loadDiff = useCallback((path: string): Promise<string> =>
|
2026-02-24 15:54:24 +01:00
|
|
|
tauriCall<string>('get_file_diff', { vaultPath, path }, { path }), [vaultPath])
|
2026-02-17 12:10:21 +01:00
|
|
|
|
2026-02-24 15:54:24 +01:00
|
|
|
const getNoteStatus = useCallback((path: string): NoteStatus =>
|
2026-02-27 18:17:47 +01:00
|
|
|
resolveNoteStatus(path, tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths), [tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths])
|
2026-02-22 10:55:45 +01:00
|
|
|
|
2026-03-19 09:51:06 +01:00
|
|
|
const commitAndPush = useCallback((message: string): Promise<GitPushResult> =>
|
2026-02-24 15:54:24 +01:00
|
|
|
commitWithPush(vaultPath, message), [vaultPath])
|
2026-02-17 12:10:21 +01:00
|
|
|
|
2026-04-02 13:47:50 +02:00
|
|
|
const reloadFolders = useCallback(
|
|
|
|
|
() => tauriCall<FolderNode[]>('list_vault_folders', { path: vaultPath })
|
|
|
|
|
.then((f) => { setFolders(f ?? []) })
|
|
|
|
|
.catch(() => { /* folders are optional — ignore errors */ }),
|
|
|
|
|
[vaultPath],
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-26 11:41:46 +01:00
|
|
|
const reloadVault = useCallback(
|
2026-03-09 13:05:18 +01:00
|
|
|
() => {
|
|
|
|
|
clearPrefetchCache()
|
|
|
|
|
return tauriCall<VaultEntry[]>('reload_vault', { path: vaultPath })
|
|
|
|
|
.then((entries) => { setEntries(entries); loadModifiedFiles(); return entries })
|
|
|
|
|
.catch((err) => { console.warn('Vault reload failed:', err); return [] as VaultEntry[] })
|
|
|
|
|
},
|
2026-02-26 11:41:46 +01:00
|
|
|
[vaultPath, loadModifiedFiles],
|
|
|
|
|
)
|
feat: auto-pull vault changes from Git (background sync + conflict handling) (#79)
* feat: add auto-pull vault sync with conflict handling
- Add git_pull, has_remote, get_conflict_files to Rust backend
- Add GitPullResult type and auto_pull_interval_minutes to Settings
- Create useAutoSync hook (pull on launch, focus, periodic interval)
- Update StatusBar with real sync status indicator
- Add pull interval setting to SettingsPanel
- Add mock handler for git_pull
- Update existing tests for new Settings field
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: add tests for git pull, settings, and useAutoSync hook
- Add Rust tests: has_remote, git_pull (no_remote, up_to_date, updated),
get_conflict_files, parse_updated_files, GitPullResult serialization
- Add frontend tests: useAutoSync (mount pull, focus pull, conflict,
error, manual trigger, concurrent prevention, no_remote)
- Fix existing settings tests for new auto_pull_interval_minutes field
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* design: add auto-pull-vault wireframes
Frames showing: sync idle, syncing, conflict indicator,
settings sync section, and conflict toast notification.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add auto_pull_interval_minutes to all Settings literals
Fix build error and test fixtures missing the new field.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: cargo fmt
* ci: re-trigger CI after flaky test
* ci: retrigger after disk space cleanup
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 01:55:16 +01:00
|
|
|
|
2026-04-02 16:09:43 +02:00
|
|
|
const reloadViews = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
setViews(await tauriCall<ViewFile[]>('list_views', { vaultPath }) ?? [])
|
|
|
|
|
} catch { /* views are optional */ }
|
|
|
|
|
}, [vaultPath])
|
|
|
|
|
|
2026-02-17 12:10:21 +01:00
|
|
|
return {
|
2026-04-02 16:09:43 +02:00
|
|
|
entries, folders, views, modifiedFiles, modifiedFilesError,
|
2026-03-08 22:15:08 +01:00
|
|
|
addEntry, updateEntry, removeEntry, replaceEntry,
|
2026-02-24 15:54:24 +01:00
|
|
|
loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit,
|
2026-04-02 16:09:43 +02:00
|
|
|
getNoteStatus, commitAndPush, reloadVault, reloadFolders, reloadViews,
|
2026-02-27 14:11:31 +01:00
|
|
|
addPendingSave: pendingSave.addPendingSave,
|
|
|
|
|
removePendingSave: pendingSave.removePendingSave,
|
2026-02-27 18:17:47 +01:00
|
|
|
unsavedPaths: unsaved.unsavedPaths,
|
|
|
|
|
trackUnsaved: unsaved.trackUnsaved,
|
|
|
|
|
clearUnsaved: unsaved.clearUnsaved,
|
2026-02-17 12:10:21 +01:00
|
|
|
}
|
|
|
|
|
}
|