diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22c1d0f8..a75ef759 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,9 +84,6 @@ jobs: # ── 5. Lint & format ────────────────────────────────────────────────── - name: Lint frontend run: pnpm lint - continue-on-error: true - # NOTE: codebase has ~60 pre-existing lint errors (no-explicit-any, react hooks, etc.) - # tracked in Todoist — fix before removing continue-on-error - name: Clippy (Rust) run: cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings diff --git a/eslint.config.js b/eslint.config.js index 5e6b472f..b50d4e1f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'coverage']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/src/App.test.tsx b/src/App.test.tsx index 76beef7a..4e49da70 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -83,7 +83,7 @@ vi.mock('@blocknote/react', () => ({ })) vi.mock('@blocknote/mantine', () => ({ - BlockNoteView: ({ children }: any) =>
{children}
, + BlockNoteView: ({ children }: { children?: React.ReactNode }) =>
{children}
, })) vi.mock('@blocknote/mantine/style.css', () => ({})) 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/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index 30e190b0..b36ac11f 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -30,6 +30,7 @@ export const RELATIONSHIP_KEYS = new Set([ // Keys to skip showing in Properties const SKIP_KEYS = new Set(['aliases', 'notion_id', 'workspace']) +// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component export function containsWikilinks(value: FrontmatterValue): boolean { if (typeof value === 'string') return /^\[\[.*\]\]$/.test(value) if (Array.isArray(value)) return value.some(v => typeof v === 'string' && /^\[\[.*\]\]$/.test(v)) diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 89aea5dc..c97e6068 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -4,13 +4,13 @@ import { describe, it, expect, vi } from 'vitest' // Hoisted mock editor — available before vi.mock factory runs. // Tests can reconfigure spies (e.g. mockTryParse.mockResolvedValue) before rendering. const mockEditor = vi.hoisted(() => ({ - tryParseMarkdownToBlocks: vi.fn(async () => [] as any[]), + tryParseMarkdownToBlocks: vi.fn(async () => [] as unknown[]), replaceBlocks: vi.fn(), insertBlocks: vi.fn(), document: [{ id: '1', type: 'paragraph', content: [], props: {}, children: [] }], insertInlineContent: vi.fn(), onMount: vi.fn((cb: () => void) => { cb(); return () => {} }), - prosemirrorView: {} as any, + prosemirrorView: {} as Record, blocksToHTMLLossy: vi.fn(() => ''), _tiptapEditor: { commands: { setContent: vi.fn() } }, })) @@ -33,7 +33,7 @@ vi.mock('@blocknote/react', () => ({ })) vi.mock('@blocknote/mantine', () => ({ - BlockNoteView: ({ children }: any) =>
{children}
, + BlockNoteView: ({ children }: { children?: React.ReactNode }) =>
{children}
, })) vi.mock('@blocknote/mantine/style.css', () => ({})) diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 8dcf883a..0e8c7eb6 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 @@ -238,6 +238,7 @@ export const Editor = memo(function Editor({ }, }) // Cache parsed blocks per tab path for instant switching + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays const tabCacheRef = useRef>(new Map()) const prevActivePathRef = useRef(null) const editorMountedRef = useRef(false) @@ -281,6 +282,7 @@ export const Editor = memo(function Editor({ const tab = tabs.find(t => t.entry.path === activeTabPath) if (!tab) return + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote's PartialBlock generic is extremely complex const applyBlocks = (blocks: any[]) => { try { const current = editor.document @@ -317,6 +319,7 @@ export const Editor = memo(function Editor({ try { const result = editor.tryParseMarkdownToBlocks(preprocessed) + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays const handleBlocks = (blocks: any[]) => { if (prevActivePathRef.current !== targetPath) return const withWikilinks = injectWikilinks(blocks) @@ -326,13 +329,15 @@ export const Editor = memo(function Editor({ } applyBlocks(withWikilinks) } + /* eslint-disable @typescript-eslint/no-explicit-any -- tryParseMarkdownToBlocks returns sync or async BlockNote blocks */ if (result && typeof (result as any).then === 'function') { - (result as unknown as Promise).then(handleBlocks).catch((err) => { + (result as unknown as Promise).then(handleBlocks).catch((err: unknown) => { console.error('Async markdown parse failed:', err) }) } else { handleBlocks(result as any[]) } + /* eslint-enable @typescript-eslint/no-explicit-any */ } catch (err) { console.error('Failed to parse/swap editor content:', err) } diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index b07a30cd..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 { @@ -20,16 +20,25 @@ const TYPE_ICON_MAP: Record>> Type: StackSimple, } +// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component export function getTypeIcon(isA: string | null, customIcon?: string | null): ComponentType> { if (customIcon) return resolveIcon(customIcon) 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} @@ -46,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 0180b6a0..d35552b0 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -15,7 +15,7 @@ import { loadSortPreferences, saveSortPreferences, } from '../utils/noteListHelpers' -// Re-export for consumers +// eslint-disable-next-line react-refresh/only-export-components -- re-exports for consumers export { sortByModified, filterEntries, buildRelationshipGroups, getSortComparator } export type { SortOption } @@ -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' @@ -188,9 +189,9 @@ function useNoteListData({ entries, selection, allContent, query, listSort, modi const searched = useMemo(() => { if (isEntityView) return [] - const sorted = [...filterEntries(entries, selection, modifiedFiles)].sort(getSortComparator(listSort)) + 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()) @@ -219,13 +220,13 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF }, []) const toggleGroup = useCallback((label: string) => { - setCollapsedGroups((prev) => { const next = new Set(prev); next.has(label) ? next.delete(label) : next.add(label); return next }) + setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(label)) next.delete(label); else next.add(label); return next }) }, []) 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/components/SidebarParts.tsx b/src/components/SidebarParts.tsx index e6b10b75..84dc5ead 100644 --- a/src/components/SidebarParts.tsx +++ b/src/components/SidebarParts.tsx @@ -12,6 +12,7 @@ export interface SectionGroup { customColor?: string | null } +// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component export function isSelectionActive(current: SidebarSelection, check: SidebarSelection): boolean { if (current.kind !== check.kind) return false switch (check.kind) { diff --git a/src/components/TypeCustomizePopover.tsx b/src/components/TypeCustomizePopover.tsx index 83bef593..222a456d 100644 --- a/src/components/TypeCustomizePopover.tsx +++ b/src/components/TypeCustomizePopover.tsx @@ -11,6 +11,7 @@ import { ACCENT_COLORS } from '../utils/typeColors' import { cn } from '@/lib/utils' /** Curated Phosphor icons (normal weight) for type customization */ +// eslint-disable-next-line react-refresh/only-export-components -- constant co-located with component export const ICON_OPTIONS: { name: string; Icon: ComponentType }[] = [ { name: 'file-text', Icon: FileText }, { name: 'wrench', Icon: Wrench }, @@ -54,6 +55,7 @@ const ICON_MAP: Record> = Object.fromEntries( ) /** Resolves a Phosphor icon name to its component, with fallback to FileText */ +// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component export function resolveIcon(name: string | null): ComponentType { return (name && ICON_MAP[name]) || FileText } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index beb56ed1..db73602b 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -45,4 +45,5 @@ function Badge({ ) } +// eslint-disable-next-line react-refresh/only-export-components -- shadcn/ui pattern export { Badge, badgeVariants } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index b5ea4abd..5fef1d15 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -61,4 +61,5 @@ function Button({ ) } +// eslint-disable-next-line react-refresh/only-export-components -- shadcn/ui pattern export { Button, buttonVariants } diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 7bf18aa7..b8b95294 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -86,4 +86,5 @@ function TabsContent({ ) } +// eslint-disable-next-line react-refresh/only-export-components -- shadcn/ui pattern export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } 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/useMcpBridge.ts b/src/hooks/useMcpBridge.ts index 2279b014..e82890e2 100644 --- a/src/hooks/useMcpBridge.ts +++ b/src/hooks/useMcpBridge.ts @@ -11,8 +11,8 @@ import { useCallback, useRef, useState } from 'react' const DEFAULT_WS_URL = 'ws://localhost:9710' interface PendingRequest { - resolve: (value: any) => void - reject: (reason: any) => void + resolve: (value: unknown) => void + reject: (reason: unknown) => void } interface SearchResult { @@ -68,12 +68,12 @@ export function useMcpBridge(wsUrl = DEFAULT_WS_URL) { }) }, [wsUrl]) - const callTool = useCallback(async (tool: string, args: Record): Promise => { + const callTool = useCallback(async (tool: string, args: Record): Promise => { const ws = await ensureConnection() const id = `mcp-${++idCounterRef.current}` return new Promise((resolve, reject) => { - pendingRef.current.set(id, { resolve, reject }) + pendingRef.current.set(id, { resolve: resolve as (value: unknown) => void, reject }) ws.send(JSON.stringify({ id, tool, args })) // Timeout after 30 seconds diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index c62fe05f..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,9 +152,8 @@ 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 } - // eslint-disable-next-line react-hooks/exhaustive-deps }, []) return { 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/mock-tauri.ts b/src/mock-tauri.ts index af51761e..12996a31 100644 --- a/src/mock-tauri.ts +++ b/src/mock-tauri.ts @@ -1641,6 +1641,7 @@ index abc1234..${shortHash} 100644 let mockHasChanges = true +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types const mockHandlers: Record any> = { list_vault: () => MOCK_ENTRIES, get_note_content: (args: { path: string }) => MOCK_CONTENT[args.path] ?? '', @@ -1656,7 +1657,7 @@ const mockHandlers: Record any> = { git_push: () => { return 'Everything up-to-date' }, - ai_chat: (args: { request: { messages: any[]; model?: string; system?: string } }) => { + ai_chat: (args: { request: { messages: { role: string; content: string }[]; model?: string; system?: string } }) => { const lastMsg = args.request.messages[args.request.messages.length - 1]?.content ?? '' const lower = lastMsg.toLowerCase() let content = `I can help you with that. Could you provide more details about what you'd like to know?` @@ -1742,24 +1743,24 @@ export function updateMockContent(path: string, content: string) { } } -export async function mockInvoke(cmd: string, args?: any): Promise { +export async function mockInvoke(cmd: string, args?: Record): Promise { // Try the vault API first for commands that read vault data const apiAvailable = await checkVaultApi() if (apiAvailable) { try { if (cmd === 'list_vault' && args?.path) { - const res = await fetch(`/api/vault/list?path=${encodeURIComponent(args.path)}`) + const res = await fetch(`/api/vault/list?path=${encodeURIComponent(args.path as string)}`) if (res.ok) return (await res.json()) as T } if (cmd === 'get_note_content' && args?.path) { - const res = await fetch(`/api/vault/content?path=${encodeURIComponent(args.path)}`) + const res = await fetch(`/api/vault/content?path=${encodeURIComponent(args.path as string)}`) if (res.ok) { const { content } = await res.json() return content as T } } if (cmd === 'get_all_content' && args?.path) { - const res = await fetch(`/api/vault/all-content?path=${encodeURIComponent(args.path)}`) + const res = await fetch(`/api/vault/all-content?path=${encodeURIComponent(args.path as string)}`) if (res.ok) return (await res.json()) as T } } catch (err) { diff --git a/src/utils/ai-chat.ts b/src/utils/ai-chat.ts index df789e76..2c0535f3 100644 --- a/src/utils/ai-chat.ts +++ b/src/utils/ai-chat.ts @@ -25,7 +25,7 @@ export function estimateTokens(text: string | number): number { const DEFAULT_CONTEXT_LIMIT = 180_000 -export function getContextLimit(_model: string): number { +export function getContextLimit(): number { return DEFAULT_CONTEXT_LIMIT } @@ -35,13 +35,12 @@ export function getContextLimit(_model: string): 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 } } - const contextBudget = Math.floor(getContextLimit(model) * 0.6) + const contextBudget = Math.floor(getContextLimit() * 0.6) const preamble = [ 'You are a helpful AI assistant integrated into Laputa, a personal knowledge management app.', 'The user has selected the following notes as context. Use them to answer questions accurately.', @@ -177,8 +176,8 @@ export async function streamChat( await readSseStream(reader, onChunk) onDone() - } catch (err: any) { - onError(err.message || 'Network error') + } catch (err: unknown) { + onError(err instanceof Error ? err.message : 'Network error') } } diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts index 6d0f77b4..bea126c7 100644 --- a/src/utils/frontmatter.ts +++ b/src/utils/frontmatter.ts @@ -40,7 +40,7 @@ export function parseFrontmatter(content: string | null): ParsedFrontmatter { let inList = false for (const line of match[1].split('\n')) { - const listMatch = line.match(/^ - (.*)$/) + const listMatch = line.match(/^ {2}- (.*)$/) if (listMatch && currentKey) { inList = true currentList.push(unquote(listMatch[1])) diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index 8c2271ba..2d9edbb6 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -1,4 +1,4 @@ -import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types' +import type { VaultEntry, SidebarSelection } from '../types' export interface RelationshipGroup { label: string @@ -196,6 +196,6 @@ function filterByFilterType(entries: VaultEntry[], filter: string): VaultEntry[] return [] } -export function filterEntries(entries: VaultEntry[], selection: SidebarSelection, _modifiedFiles?: ModifiedFile[]): VaultEntry[] { +export function filterEntries(entries: VaultEntry[], selection: SidebarSelection): VaultEntry[] { return filterByKind(entries, selection) } diff --git a/src/utils/wikilinks.ts b/src/utils/wikilinks.ts index 2fafa685..3d656159 100644 --- a/src/utils/wikilinks.ts +++ b/src/utils/wikilinks.ts @@ -8,21 +8,36 @@ export function preProcessWikilinks(md: string): string { return md.replace(/\[\[([^\]]+)\]\]/g, (_m, target) => `${WL_START}${target}${WL_END}`) } +// Minimal shape of a BlockNote block for wikilink processing +interface BlockLike { + content?: InlineItem[] + children?: BlockLike[] + [key: string]: unknown +} + +interface InlineItem { + type: string + text?: string + props?: Record + content?: unknown + [key: string]: unknown +} + /** Walk blocks and replace placeholder text with wikilink inline content */ -export function injectWikilinks(blocks: any[]): any[] { - return blocks.map(block => { +export function injectWikilinks(blocks: unknown[]): unknown[] { + return (blocks as BlockLike[]).map(block => { if (block.content && Array.isArray(block.content)) { block.content = expandWikilinksInContent(block.content) } if (block.children && Array.isArray(block.children)) { - block.children = injectWikilinks(block.children) + block.children = injectWikilinks(block.children) as BlockLike[] } return block }) } -function expandWikilinksInContent(content: any[]): any[] { - const result: any[] = [] +function expandWikilinksInContent(content: InlineItem[]): InlineItem[] { + const result: InlineItem[] = [] for (const item of content) { if (item.type !== 'text' || typeof item.text !== 'string' || !item.text.includes(WL_START)) { result.push(item) @@ -62,7 +77,7 @@ export function splitFrontmatter(content: string): [string, string] { export function countWords(content: string): number { const [, body] = splitFrontmatter(content) - const text = body.replace(/[#*_\[\]`>~\-|]/g, '').trim() + const text = body.replace(/[#*_[\]`>~\-|]/g, '').trim() if (!text) return 0 return text.split(/\s+/).filter(Boolean).length } diff --git a/vite.config.ts b/vite.config.ts index 28b70e1b..2fdcd67e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -226,7 +226,7 @@ function vaultApiPlugin(): Plugin { // --- AI Chat proxy helpers --- -function readRequestBody(req: any): Promise { +function readRequestBody(req: import('http').IncomingMessage): Promise { return new Promise((resolve) => { let body = '' req.on('data', (chunk: Buffer) => { body += chunk.toString() }) @@ -235,7 +235,7 @@ function readRequestBody(req: any): Promise { } async function forwardToAnthropic(params: { - apiKey: string; model?: string; messages: any[]; system?: string; maxTokens?: number + apiKey: string; model?: string; messages: { role: string; content: string }[]; system?: string; maxTokens?: number }): Promise { return fetch('https://api.anthropic.com/v1/messages', { method: 'POST', @@ -254,7 +254,7 @@ async function forwardToAnthropic(params: { }) } -async function streamResponseBody(source: ReadableStream, res: any): Promise { +async function streamResponseBody(source: ReadableStream, res: import('http').ServerResponse): Promise { const reader = source.getReader() const decoder = new TextDecoder() let done = false @@ -300,10 +300,10 @@ function aiChatProxyPlugin(): Plugin { } else { res.end() } - } catch (err: any) { + } catch (err: unknown) { res.statusCode = 500 res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ error: err.message || 'Internal server error' })) + res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Internal server error' })) } }) },