diff --git a/src/App.tsx b/src/App.tsx index 2545c495..bb5b7a76 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -86,6 +86,7 @@ function App() { useEffect(() => { if (!notes.activeTabPath) { setGitHistory([]); return } vault.loadGitHistory(notes.activeTabPath).then(setGitHistory) + // eslint-disable-next-line react-hooks/exhaustive-deps -- vault object is unstable; loadGitHistory is the actual dep }, [notes.activeTabPath, vault.loadGitHistory]) const openCreateTypeDialog = useCallback(() => { diff --git a/src/components/AIChatPanel.tsx b/src/components/AIChatPanel.tsx index e6957aaf..dfccf44e 100644 --- a/src/components/AIChatPanel.tsx +++ b/src/components/AIChatPanel.tsx @@ -187,6 +187,7 @@ function useContextNotes(entry: VaultEntry | null) { useEffect(() => { if (entry) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- sync context when active note changes setContextNotes(prev => prev.some(n => n.path === entry.path) ? prev : [entry, ...prev]) } }, [entry?.path]) // eslint-disable-line react-hooks/exhaustive-deps @@ -215,8 +216,8 @@ export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChat const chat = useAIChat(entry, allContent, ctx.contextNotes, model) const contextInfo = useMemo( - () => buildSystemPrompt(ctx.contextNotes, allContent, model), - [ctx.contextNotes, allContent, model], + () => buildSystemPrompt(ctx.contextNotes, allContent), + [ctx.contextNotes, allContent], ) useEffect(() => { diff --git a/src/components/CommitDialog.tsx b/src/components/CommitDialog.tsx index 25902c05..c27d6fe2 100644 --- a/src/components/CommitDialog.tsx +++ b/src/components/CommitDialog.tsx @@ -16,7 +16,7 @@ export function CommitDialog({ open, modifiedCount, onCommit, onClose }: CommitD useEffect(() => { if (open) { - setMessage('') + setMessage('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open setTimeout(() => inputRef.current?.focus(), 50) } }, [open]) diff --git a/src/components/CreateNoteDialog.tsx b/src/components/CreateNoteDialog.tsx index a0fcf972..0c107a05 100644 --- a/src/components/CreateNoteDialog.tsx +++ b/src/components/CreateNoteDialog.tsx @@ -33,8 +33,8 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT useEffect(() => { if (open) { - setTitle('') - setType(defaultType ?? 'Note') + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset on dialog open + setTitle(''); setType(defaultType ?? 'Note') setTimeout(() => inputRef.current?.focus(), 50) } }, [open, defaultType]) diff --git a/src/components/CreateTypeDialog.tsx b/src/components/CreateTypeDialog.tsx index 0dfdf90a..14bf7bf2 100644 --- a/src/components/CreateTypeDialog.tsx +++ b/src/components/CreateTypeDialog.tsx @@ -15,7 +15,7 @@ export function CreateTypeDialog({ open, onClose, onCreate }: CreateTypeDialogPr useEffect(() => { if (open) { - setName('') + setName('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open setTimeout(() => inputRef.current?.focus(), 50) } }, [open]) diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index c0f3ceca..088c40cd 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -126,7 +126,7 @@ const schema = BlockNoteSchema.create({ /** Single BlockNote editor view — content is swapped via replaceBlocks */ function SingleEditorView({ editor, entries, onNavigateWikilink }: { editor: ReturnType; entries: VaultEntry[]; onNavigateWikilink: (target: string) => void }) { const navigateRef = useRef(onNavigateWikilink) - navigateRef.current = onNavigateWikilink + useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink]) const { cssVars } = useEditorTheme() // Keep module-level ref in sync so WikiLink renderer can access vault entries diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index ef55e4c6..644290a5 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -1,4 +1,4 @@ -import type { ComponentType, SVGAttributes } from 'react' +import { useMemo, type ComponentType, type SVGAttributes } from 'react' import type { VaultEntry } from '../types' import { cn } from '@/lib/utils' import { @@ -26,11 +26,19 @@ export function getTypeIcon(isA: string | null, customIcon?: string | null): Com return (isA && TYPE_ICON_MAP[isA]) || FileText } +const THIRTY_DAYS_SECS = 86400 * 30 + function TrashDateLine({ entry }: { entry: VaultEntry }) { - const trashedAge = entry.trashedAt ? (Date.now() / 1000 - entry.trashedAt) : 0 - const isExpired = trashedAge >= 86400 * 30 + const { isExpired, suffix } = useMemo(() => { + // eslint-disable-next-line react-hooks/purity -- Date.now() intentionally memoized on trashedAt + const trashedAge = entry.trashedAt ? (Date.now() / 1000 - entry.trashedAt) : 0 + const expired = trashedAge >= THIRTY_DAYS_SECS + return { + isExpired: expired, + suffix: expired ? ' — will be permanently deleted' : '', + } + }, [entry.trashedAt]) const style = isExpired ? { color: 'var(--destructive)', fontWeight: 500 } as const : undefined - const suffix = isExpired ? ' — will be permanently deleted' : '' return (
Trashed {relativeDate(entry.trashedAt)}{suffix} @@ -47,7 +55,7 @@ export function NoteItem({ entry, isSelected, typeEntryMap, onSelectNote }: { const te = typeEntryMap[entry.isA ?? ''] const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color) const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color) - const TypeIcon = getTypeIcon(entry.isA, te?.icon) + const TypeIcon = useMemo(() => getTypeIcon(entry.isA, te?.icon), [entry.isA, te?.icon]) return (
onSelectNote(entry)} > + {/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index df27b172..d35552b0 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -38,9 +38,10 @@ function PinnedCard({ entry, typeEntryMap, onSelectNote, showDate }: { const te = typeEntryMap[entry.isA ?? ''] const color = getTypeColor(entry.isA ?? '', te?.color) const bgColor = getTypeLightColor(entry.isA ?? '', te?.color) - const Icon = getTypeIcon(entry.isA, te?.icon) + const Icon = useMemo(() => getTypeIcon(entry.isA, te?.icon), [entry.isA, te?.icon]) return (
onSelectNote(entry)}> + {/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
{entry.title}
{entry.snippet}
@@ -174,10 +175,10 @@ function countExpiredTrash(entries: VaultEntry[]): number { interface NoteListDataParams { entries: VaultEntry[]; selection: SidebarSelection; allContent: Record - query: string; listSort: SortOption; modifiedFiles?: ModifiedFile[] + query: string; listSort: SortOption } -function useNoteListData({ entries, selection, allContent, query, listSort, modifiedFiles }: NoteListDataParams) { +function useNoteListData({ entries, selection, allContent, query, listSort }: NoteListDataParams) { const isEntityView = selection.kind === 'entity' const isTrashView = selection.kind === 'filter' && selection.filter === 'trash' @@ -190,7 +191,7 @@ function useNoteListData({ entries, selection, allContent, query, listSort, modi if (isEntityView) return [] const sorted = [...filterEntries(entries, selection)].sort(getSortComparator(listSort)) return filterByQuery(sorted, query) - }, [entries, selection, modifiedFiles, isEntityView, listSort, query]) + }, [entries, selection, isEntityView, listSort, query]) const searchedGroups = useMemo(() => { if (!isEntityView) return [] @@ -208,7 +209,7 @@ function useNoteListData({ entries, selection, allContent, query, listSort, modi // --- Main component --- -function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) { +function NoteListInner({ entries, selection, selectedNote, allContent, onSelectNote, onCreateNote }: NoteListProps) { const [search, setSearch] = useState('') const [searchVisible, setSearchVisible] = useState(false) const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) @@ -225,7 +226,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF const typeEntryMap = useTypeEntryMap(entries) const query = search.trim().toLowerCase() const listSort = sortPrefs['__list__'] ?? 'modified' - const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, modifiedFiles }) + const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort }) const renderItem = useCallback((entry: VaultEntry) => ( diff --git a/src/components/QuickOpenPalette.tsx b/src/components/QuickOpenPalette.tsx index 0d50f84e..4f6aae0b 100644 --- a/src/components/QuickOpenPalette.tsx +++ b/src/components/QuickOpenPalette.tsx @@ -39,8 +39,8 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen useEffect(() => { if (open) { - setQuery('') - setSelectedIndex(0) + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset on dialog open + setQuery(''); setSelectedIndex(0) setTimeout(() => inputRef.current?.focus(), 50) } }, [open]) @@ -58,7 +58,7 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen }, [entries, query]) useEffect(() => { - setSelectedIndex(0) + setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset selection on query change }, [query]) useEffect(() => { diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 0a79ddad..645f0df1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -56,7 +56,7 @@ function useOutsideClick(ref: React.RefObject, isOpen: boole } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) - }, [isOpen, onClose]) + }, [ref, isOpen, onClose]) } function buildTypeEntryMap(entries: VaultEntry[]): Record { diff --git a/src/hooks/useAIChat.ts b/src/hooks/useAIChat.ts index 52ded113..9a817369 100644 --- a/src/hooks/useAIChat.ts +++ b/src/hooks/useAIChat.ts @@ -61,7 +61,7 @@ export function useAIChat( return } - const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent, model) + const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent) let accumulated = '' const onChunk = (chunk: string) => { diff --git a/src/hooks/useAppKeyboard.ts b/src/hooks/useAppKeyboard.ts index 4ada4e22..a6044972 100644 --- a/src/hooks/useAppKeyboard.ts +++ b/src/hooks/useAppKeyboard.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react' +import { useEffect } from 'react' interface KeyboardActions { onQuickOpen: () => void @@ -16,22 +16,22 @@ export function useAppKeyboard({ onQuickOpen, onCreateNote, onSave, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, }: KeyboardActions) { - const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => { - const path = activeTabPathRef.current - if (path) fn(path) - } - - const keyMap = useMemo((): Record => ({ - p: onQuickOpen, - n: onCreateNote, - s: onSave, - e: withActiveTab(onArchiveNote), - w: withActiveTab((path) => handleCloseTabRef.current(path)), - Backspace: withActiveTab(onTrashNote), - Delete: withActiveTab(onTrashNote), - }), [onQuickOpen, onCreateNote, onSave, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef]) - useEffect(() => { + const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => { + const path = activeTabPathRef.current + if (path) fn(path) + } + + const keyMap: Record = { + p: onQuickOpen, + n: onCreateNote, + s: onSave, + e: withActiveTab(onArchiveNote), + w: withActiveTab((path) => handleCloseTabRef.current(path)), + Backspace: withActiveTab(onTrashNote), + Delete: withActiveTab(onTrashNote), + } + const handleKeyDown = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey if (!mod) return @@ -43,5 +43,5 @@ export function useAppKeyboard({ } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [keyMap]) + }, [onQuickOpen, onCreateNote, onSave, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef]) } diff --git a/src/hooks/useKeyboardNavigation.ts b/src/hooks/useKeyboardNavigation.ts index ed3fa566..8e93baf2 100644 --- a/src/hooks/useKeyboardNavigation.ts +++ b/src/hooks/useKeyboardNavigation.ts @@ -88,7 +88,7 @@ function arrowDirection(key: string): 1 | -1 { function useLatestRef(value: T): React.RefObject { const ref = useRef(value) - ref.current = value + useEffect(() => { ref.current = value }) return ref } @@ -119,5 +119,5 @@ export function useKeyboardNavigation({ } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, []) + }, [tabsRef, activeTabPathRef, visibleNotesRef, onSwitchTabRef, onReplaceRef, onSelectNoteRef]) } diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index c6e8b7a0..f204ed74 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -82,9 +82,9 @@ export function useTabManagement() { const [tabs, setTabs] = useState([]) const [activeTabPath, setActiveTabPath] = useState(null) const activeTabPathRef = useRef(activeTabPath) - activeTabPathRef.current = activeTabPath + useEffect(() => { activeTabPathRef.current = activeTabPath }) const tabsRef = useRef(tabs) - tabsRef.current = tabs + useEffect(() => { tabsRef.current = tabs }) const handleCloseTabRef = useRef<(path: string) => void>(() => {}) const handleSelectNote = useCallback(async (entry: VaultEntry) => { @@ -111,7 +111,7 @@ export function useTabManagement() { return next }) }, []) - handleCloseTabRef.current = handleCloseTab + useEffect(() => { handleCloseTabRef.current = handleCloseTab }) const handleSwitchTab = useCallback((path: string) => { setActiveTabPath(path) @@ -152,7 +152,7 @@ export function useTabManagement() { useEffect(() => { const savedOrder = loadTabOrder() if (savedOrder.length > 0) { - setTabs((prev) => restoreOrder(prev, savedOrder)) + setTabs((prev) => restoreOrder(prev, savedOrder)) // eslint-disable-line react-hooks/set-state-in-effect -- restore tab order on mount } }, []) diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index 762b19a9..38467908 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -36,6 +36,7 @@ export function useVaultLoader(vaultPath: string) { const [modifiedFiles, setModifiedFiles] = useState([]) useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault setEntries([]); setAllContent({}); setModifiedFiles([]) loadVaultData(vaultPath) .then(({ entries: e, allContent: c }) => { setEntries(e); setAllContent(c) }) @@ -51,7 +52,7 @@ export function useVaultLoader(vaultPath: string) { } }, [vaultPath]) - useEffect(() => { loadModifiedFiles() }, [loadModifiedFiles]) + useEffect(() => { loadModifiedFiles() }, [loadModifiedFiles]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load const addEntry = useCallback((entry: VaultEntry, content: string) => { setEntries((prev) => [entry, ...prev]) diff --git a/src/utils/ai-chat.ts b/src/utils/ai-chat.ts index 46efc99f..2c0535f3 100644 --- a/src/utils/ai-chat.ts +++ b/src/utils/ai-chat.ts @@ -35,7 +35,6 @@ export function getContextLimit(): number { export function buildSystemPrompt( notes: VaultEntry[], allContent: Record, - model: string, ): { prompt: string; totalTokens: number; truncated: boolean } { if (notes.length === 0) { return { prompt: '', totalTokens: 0, truncated: false }