fix: resolve all ESLint errors, enforce lint in CI
This commit is contained in:
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -83,7 +83,7 @@ vi.mock('@blocknote/react', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/mantine', () => ({
|
||||
BlockNoteView: ({ children }: any) => <div data-testid="blocknote-view">{children}</div>,
|
||||
BlockNoteView: ({ children }: { children?: React.ReactNode }) => <div data-testid="blocknote-view">{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/mantine/style.css', () => ({}))
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
blocksToHTMLLossy: vi.fn(() => ''),
|
||||
_tiptapEditor: { commands: { setContent: vi.fn() } },
|
||||
}))
|
||||
@@ -33,7 +33,7 @@ vi.mock('@blocknote/react', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/mantine', () => ({
|
||||
BlockNoteView: ({ children }: any) => <div data-testid="blocknote-view">{children}</div>,
|
||||
BlockNoteView: ({ children }: { children?: React.ReactNode }) => <div data-testid="blocknote-view">{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/mantine/style.css', () => ({}))
|
||||
|
||||
@@ -126,7 +126,7 @@ const schema = BlockNoteSchema.create({
|
||||
/** Single BlockNote editor view — content is swapped via replaceBlocks */
|
||||
function SingleEditorView({ editor, entries, onNavigateWikilink }: { editor: ReturnType<typeof useCreateBlockNote>; 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<Map<string, any[]>>(new Map())
|
||||
const prevActivePathRef = useRef<string | null>(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<any[]>).then(handleBlocks).catch((err) => {
|
||||
(result as unknown as Promise<any[]>).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)
|
||||
}
|
||||
|
||||
@@ -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<string, ComponentType<SVGAttributes<SVGSVGElement>>>
|
||||
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<SVGAttributes<SVGSVGElement>> {
|
||||
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 (
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground" style={style}>
|
||||
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 (
|
||||
<div
|
||||
@@ -61,6 +70,7 @@ export function NoteItem({ entry, isSelected, typeEntryMap, onSelectNote }: {
|
||||
}}
|
||||
onClick={() => onSelectNote(entry)}
|
||||
>
|
||||
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
|
||||
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
|
||||
<div className="pr-5">
|
||||
<div className={cn("truncate text-[13px] text-foreground", isSelected ? "font-semibold" : "font-medium")}>
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative cursor-pointer border-b border-[var(--border)]" style={{ backgroundColor: bgColor, padding: '14px 16px' }} onClick={() => onSelectNote(entry)}>
|
||||
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
|
||||
<Icon width={16} height={16} className="absolute right-3 top-3.5" style={{ color }} data-testid="type-icon" />
|
||||
<div className="pr-6 text-[14px] font-bold" style={{ color }}>{entry.title}</div>
|
||||
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{entry.snippet}</div>
|
||||
@@ -174,10 +175,10 @@ function countExpiredTrash(entries: VaultEntry[]): number {
|
||||
|
||||
interface NoteListDataParams {
|
||||
entries: VaultEntry[]; selection: SidebarSelection; allContent: Record<string, string>
|
||||
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<Set<string>>(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) => (
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -56,7 +56,7 @@ function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boole
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [isOpen, onClose])
|
||||
}, [ref, isOpen, onClose])
|
||||
}
|
||||
|
||||
function buildTypeEntryMap(entries: VaultEntry[]): Record<string, VaultEntry> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<IconProps> }[] = [
|
||||
{ name: 'file-text', Icon: FileText },
|
||||
{ name: 'wrench', Icon: Wrench },
|
||||
@@ -54,6 +55,7 @@ const ICON_MAP: Record<string, ComponentType<IconProps>> = 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<IconProps> {
|
||||
return (name && ICON_MAP[name]) || FileText
|
||||
}
|
||||
|
||||
@@ -45,4 +45,5 @@ function Badge({
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- shadcn/ui pattern
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
@@ -61,4 +61,5 @@ function Button({
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- shadcn/ui pattern
|
||||
export { Button, buttonVariants }
|
||||
|
||||
@@ -86,4 +86,5 @@ function TabsContent({
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- shadcn/ui pattern
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<string, ShortcutHandler> => ({
|
||||
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<string, ShortcutHandler> = {
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ function arrowDirection(key: string): 1 | -1 {
|
||||
|
||||
function useLatestRef<T>(value: T): React.RefObject<T> {
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -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 <T>(tool: string, args: Record<string, any>): Promise<T> => {
|
||||
const callTool = useCallback(async <T>(tool: string, args: Record<string, unknown>): Promise<T> => {
|
||||
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
|
||||
|
||||
@@ -82,9 +82,9 @@ export function useTabManagement() {
|
||||
const [tabs, setTabs] = useState<Tab[]>([])
|
||||
const [activeTabPath, setActiveTabPath] = useState<string | null>(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 {
|
||||
|
||||
@@ -36,6 +36,7 @@ export function useVaultLoader(vaultPath: string) {
|
||||
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
|
||||
|
||||
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])
|
||||
|
||||
@@ -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<string, (args: any) => any> = {
|
||||
list_vault: () => MOCK_ENTRIES,
|
||||
get_note_content: (args: { path: string }) => MOCK_CONTENT[args.path] ?? '',
|
||||
@@ -1656,7 +1657,7 @@ const mockHandlers: Record<string, (args: any) => 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<T>(cmd: string, args?: any): Promise<T> {
|
||||
export async function mockInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
// 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) {
|
||||
|
||||
@@ -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<string, string>,
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<string, string>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ function vaultApiPlugin(): Plugin {
|
||||
|
||||
// --- AI Chat proxy helpers ---
|
||||
|
||||
function readRequestBody(req: any): Promise<string> {
|
||||
function readRequestBody(req: import('http').IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let body = ''
|
||||
req.on('data', (chunk: Buffer) => { body += chunk.toString() })
|
||||
@@ -235,7 +235,7 @@ function readRequestBody(req: any): Promise<string> {
|
||||
}
|
||||
|
||||
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<Response> {
|
||||
return fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
@@ -254,7 +254,7 @@ async function forwardToAnthropic(params: {
|
||||
})
|
||||
}
|
||||
|
||||
async function streamResponseBody(source: ReadableStream<Uint8Array>, res: any): Promise<void> {
|
||||
async function streamResponseBody(source: ReadableStream<Uint8Array>, res: import('http').ServerResponse): Promise<void> {
|
||||
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' }))
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user