From ad9d0e5e67b77e980f62a9b11efc045002b8bf24 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 25 Mar 2026 09:46:15 +0100 Subject: [PATCH] =?UTF-8?q?refactor:=20RelationshipsPanel=20=E2=80=94=20ex?= =?UTF-8?q?tract=20shared=20hooks=20to=20reduce=20complexity=20(avg:=209.3?= =?UTF-8?q?6,=20gate:=208.90=20=E2=86=92=209.31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract useSearchKeyboard, useCreateAndOpen, and useCreateOption hooks from duplicated logic in InlineAddNote, NoteTargetInput, and AddRelationshipForm. File score: 7.8 → 8.48. Raise average code health gate threshold to 9.31. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 2 +- .husky/pre-commit | 6 +- .husky/pre-push | 6 +- CLAUDE.md | 4 +- .../inspector/RelationshipsPanel.tsx | 187 +++++++++--------- 5 files changed, 103 insertions(+), 102 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1443c484..557fb429 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,7 +91,7 @@ jobs: CODESCENE_PROJECT_ID: ${{ secrets.CODESCENE_PROJECT_ID }} run: | HOTSPOT_THRESHOLD=9.5 - AVERAGE_THRESHOLD=8.9 + AVERAGE_THRESHOLD=9.31 API_RESPONSE=$(curl -sf \ -H "Authorization: Bearer $CODESCENE_PAT" \ -H "Accept: application/json" \ diff --git a/.husky/pre-commit b/.husky/pre-commit index 1a50ca23..018c3021 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -48,12 +48,12 @@ if hotspot < 9.5: failed = True else: print(f'OK: Hotspot {hotspot:.2f} >= 9.5') -if average < 8.9: - print(f'FAIL: Average Code Health {average:.2f} < 9.0 — recent changes introduced regressions in non-hotspot files') +if average < 9.31: + print(f'FAIL: Average Code Health {average:.2f} < 9.31 — recent changes introduced regressions in non-hotspot files') print(' Review files changed in this task. Never use eslint-disable, #[allow(...)], or as any to bypass.') failed = True else: - print(f'OK: Average {average:.2f} >= 9.0') + print(f'OK: Average {average:.2f} >= 9.31') if failed: sys.exit(1) " || exit 1 diff --git a/.husky/pre-push b/.husky/pre-push index 0d03e590..1d1f3840 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -134,11 +134,11 @@ if hotspot < 9.5: failed = True else: print(f'OK: Hotspot {hotspot:.2f} >= 9.5') -if average < 8.9: - print(f'FAIL: Average Code Health {average:.2f} < 9.0 — regressions detected, fix before pushing') +if average < 9.31: + print(f'FAIL: Average Code Health {average:.2f} < 9.31 — regressions detected, fix before pushing') failed = True else: - print(f'OK: Average {average:.2f} >= 9.0') + print(f'OK: Average {average:.2f} >= 9.31') if failed: sys.exit(1) " || exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index eb9b82dd..98f88a6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,11 +12,11 @@ cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-line **CodeScene Code Health** — the pre-commit and pre-push hooks enforce: - Hotspot Code Health ≥ 9.5 (most-edited files) -- Average Code Health ≥ 9.0 (project-wide, ALL files) +- Average Code Health ≥ 9.31 (project-wide, ALL files) **Both gates block commit/push.** If either fails: extract hooks, split large components, reduce function complexity. Never add `// eslint-disable`, `#[allow(...)]`, or `as any` to pass the gate. Check both scores via MCP CodeScene after every significant change: - `hotspot_code_health.now` ≥ 9.5 -- `code_health.now` ≥ 9.0 (average — do NOT ignore this one) +- `code_health.now` ≥ 9.31 (average — do NOT ignore this one) If Average Code Health is below 9.0, you must fix regressions before pushing — even in files you didn't directly modify, if your changes indirectly affected complexity. diff --git a/src/components/inspector/RelationshipsPanel.tsx b/src/components/inspector/RelationshipsPanel.tsx index 35392aa6..125227a6 100644 --- a/src/components/inspector/RelationshipsPanel.tsx +++ b/src/components/inspector/RelationshipsPanel.tsx @@ -15,6 +15,57 @@ function hasExactTitleMatch(entries: VaultEntry[], title: string): boolean { return resolveEntry(entries, title) !== undefined } +/** Shared keyboard navigation for search dropdowns with an optional "create" item. */ +function useSearchKeyboard( + search: ReturnType, + totalItems: number, + onConfirm: () => void, + onEscape: () => void, +) { + return useCallback((e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + search.setSelectedIndex((i: number) => Math.min(i + 1, totalItems - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + search.setSelectedIndex((i: number) => Math.max(i - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + onConfirm() + } else if (e.key === 'Escape') { + onEscape() + } + }, [search, totalItems, onConfirm, onEscape]) +} + +/** Wraps the create-and-open-note pattern: calls the async creator, then defers a side-effect to the next tick. */ +function useCreateAndOpen( + onCreateAndOpenNote: ((title: string) => Promise) | undefined, + afterCreate: (title: string) => void, + onDone: () => void, +) { + return useCallback(async (title: string) => { + if (!onCreateAndOpenNote || !title) return + const ok = await onCreateAndOpenNote(title) + if (!ok) return + // Defer frontmatter update to next tick to avoid radix-ui + // infinite setState loop from overlapping render batches + setTimeout(() => afterCreate(title), 0) + onDone() + }, [onCreateAndOpenNote, afterCreate, onDone]) +} + +/** Derives create-option state from search results and entries. */ +function useCreateOption( + entries: VaultEntry[], + trimmedQuery: string, + resultCount: number, + hasCreator: boolean, +) { + const showCreate = hasCreator && trimmedQuery.length > 0 && !hasExactTitleMatch(entries, trimmedQuery) + return { showCreate, createIndex: resultCount, totalItems: resultCount + (showCreate ? 1 : 0) } +} + function CreateAndOpenOption({ title, selected, onClick, onHover }: { title: string selected: boolean @@ -45,9 +96,8 @@ function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAn onCreateAndOpen?: (title: string) => void }) { const trimmed = query.trim() - const showCreate = !!onCreateAndOpen && trimmed.length > 0 && !hasExactTitleMatch(entries, trimmed) + const { showCreate, createIndex } = useCreateOption(entries, trimmed, search.results.length, !!onCreateAndOpen) const hasResults = search.results.length > 0 - const createIndex = search.results.length if (!hasResults && !showCreate) return null @@ -86,56 +136,27 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: { const search = useNoteSearch(entries, query, 8) const trimmed = query.trim() - const showCreate = !!onCreateAndOpenNote && trimmed.length > 0 && !hasExactTitleMatch(entries, trimmed) - const createIndex = search.results.length + const { showCreate, createIndex, totalItems } = useCreateOption(entries, trimmed, search.results.length, !!onCreateAndOpenNote) + + const dismiss = useCallback(() => { setQuery(''); setActive(false) }, []) const selectAndClose = useCallback((title: string) => { onAdd(title) - setQuery('') - setActive(false) - }, [onAdd]) + dismiss() + }, [onAdd, dismiss]) - const handleCreateAndOpen = useCallback(async () => { - if (!onCreateAndOpenNote) return - const title = trimmed - if (!title) return - const ok = await onCreateAndOpenNote(title) - // Defer frontmatter update to avoid radix-ui infinite setState loop: - // onAdd triggers handleUpdateFrontmatter → setTabs in a microtask, - // which can collide with the render triggered by openTabWithContent. - if (ok) setTimeout(() => onAdd(title), 0) - if (ok) { - setQuery('') - setActive(false) - } - }, [onCreateAndOpenNote, trimmed, onAdd]) + const handleCreateAndOpen = useCreateAndOpen(onCreateAndOpenNote, onAdd, dismiss) const handleConfirm = useCallback(() => { if (showCreate && search.selectedIndex === createIndex) { - handleCreateAndOpen() + handleCreateAndOpen(trimmed) return } const title = search.selectedEntry?.title ?? trimmed if (title) selectAndClose(title) }, [search.selectedEntry, search.selectedIndex, trimmed, selectAndClose, showCreate, createIndex, handleCreateAndOpen]) - const totalItems = search.results.length + (showCreate ? 1 : 0) - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'ArrowDown') { - e.preventDefault() - search.setSelectedIndex((i: number) => Math.min(i + 1, totalItems - 1)) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - search.setSelectedIndex((i: number) => Math.max(i - 1, 0)) - } else if (e.key === 'Enter') { - e.preventDefault() - handleConfirm() - } else if (e.key === 'Escape') { - setQuery('') - setActive(false) - } - }, [search, totalItems, handleConfirm]) + const handleKeyDown = useSearchKeyboard(search, totalItems, handleConfirm, dismiss) if (!active) { return ( @@ -150,7 +171,7 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: { ) } - const showDropdown = query.trim().length > 0 && (search.results.length > 0 || showCreate) + const showDropdown = trimmed.length > 0 && (search.results.length > 0 || showCreate) return (
@@ -168,7 +189,7 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: { /> @@ -179,18 +200,7 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: { onSelect={selectAndClose} query={query} entries={entries} - onCreateAndOpen={onCreateAndOpenNote ? (title) => { - const fn = async () => { - const ok = await onCreateAndOpenNote(title) - if (ok) { - // Defer frontmatter update to next tick to avoid radix-ui - // infinite setState loop from overlapping render batches - setTimeout(() => onAdd(title), 0) - setQuery(''); setActive(false) - } - } - fn() - } : undefined} + onCreateAndOpen={onCreateAndOpenNote ? (title) => { handleCreateAndOpen(title) } : undefined} /> )}
@@ -257,31 +267,22 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat const search = useNoteSearch(entries, value, 8) const trimmed = value.trim() - const showCreate = !!onCreateAndOpenNote && trimmed.length > 0 && !hasExactTitleMatch(entries, trimmed) - const createIndex = search.results.length - const totalItems = search.results.length + (showCreate ? 1 : 0) + const { showCreate, createIndex, totalItems } = useCreateOption(entries, trimmed, search.results.length, !!onCreateAndOpenNote) - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'ArrowDown') { - e.preventDefault() - search.setSelectedIndex((i: number) => Math.min(i + 1, totalItems - 1)) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - search.setSelectedIndex((i: number) => Math.max(i - 1, 0)) - } else if (e.key === 'Enter') { - e.preventDefault() - if (showCreate && search.selectedIndex === createIndex) { - onSubmitWithCreate?.(trimmed) - } else if (search.selectedEntry) { - onChange(search.selectedEntry.title) - setFocused(false) - } else { - onSubmit?.() - } - } else if (e.key === 'Escape') { - onCancel?.() + const handleConfirm = useCallback(() => { + if (showCreate && search.selectedIndex === createIndex) { + onSubmitWithCreate?.(trimmed) + } else if (search.selectedEntry) { + onChange(search.selectedEntry.title) + setFocused(false) + } else { + onSubmit?.() } - }, [search, totalItems, showCreate, createIndex, trimmed, onChange, onSubmit, onCancel, onSubmitWithCreate]) + }, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, onChange, onSubmit, onSubmitWithCreate]) + + const handleEscape = useCallback(() => { onCancel?.() }, [onCancel]) + + const handleKeyDown = useSearchKeyboard(search, totalItems, handleConfirm, handleEscape) const showDropdown = focused && trimmed.length > 0 && (search.results.length > 0 || showCreate) @@ -320,30 +321,24 @@ function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: { const [showForm, setShowForm] = useState(false) const keyInputRef = useRef(null) + const resetForm = useCallback(() => { + setShowForm(false); setRelKey(''); setRelTarget('') + }, []) + const submitForm = useCallback((targetOverride?: string) => { const key = relKey.trim() const target = (targetOverride ?? relTarget).trim() if (!key || !target) return onAddProperty(key, `[[${target}]]`) - setRelKey(''); setRelTarget(''); setShowForm(false) - }, [relKey, relTarget, onAddProperty]) + resetForm() + }, [relKey, relTarget, onAddProperty, resetForm]) - const handleCreateAndSubmit = useCallback(async (title: string) => { - if (!onCreateAndOpenNote) return + const addPropertyForKey = useCallback((title: string) => { const key = relKey.trim() - if (!key) return - const ok = await onCreateAndOpenNote(title) - if (ok) { - // Defer frontmatter update to next tick to avoid radix-ui - // infinite setState loop from overlapping render batches - setTimeout(() => onAddProperty(key, `[[${title}]]`), 0) - setRelKey(''); setRelTarget(''); setShowForm(false) - } - }, [onCreateAndOpenNote, relKey, onAddProperty]) + if (key) onAddProperty(key, `[[${title}]]`) + }, [relKey, onAddProperty]) - const resetForm = useCallback(() => { - setShowForm(false); setRelKey(''); setRelTarget('') - }, []) + const handleCreateAndSubmit = useCreateAndOpen(onCreateAndOpenNote, addPropertyForKey, resetForm) if (!showForm) { return ( @@ -393,6 +388,12 @@ function updateRefsForAddition(refs: string[], noteTitle: string): FrontmatterVa return updated.length === 1 ? updated[0] : updated } +function DisabledLinkButton() { + return ( + + ) +} + export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote }: { frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record onNavigate: (target: string) => void @@ -433,7 +434,7 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, ))} {onAddProperty ? - : + : } )