Compare commits

...

6 Commits

Author SHA1 Message Date
Test
8cb2194842 ci: add average_code_health gate (≥8.8) to pre-push hook
Previously only hotspot_code_health was checked (≥9.2).
Average code health was not gated, allowing merges that degrade
overall codebase quality without being blocked.

New gate: average_code_health ≥ 8.8 (current: ~8.9)
2026-03-13 08:26:05 +01:00
Luca Rossi
66090688f9 refactor: extract useLayoutPanels hook from App.tsx — reduce god component churn (#195) (#196)
Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:11:31 +01:00
Luca Rossi
a15f36ec6a refactor: extract useAppNavigation hook from App.tsx — reduce god component churn (#194)
Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:24:46 +01:00
Luca Rossi
8137125569 refactor: extract useDeleteActions hook from App.tsx — reduce churn surface (#193)
Extract delete/trash management logic (deleteNoteFromDisk, handleDeleteNote,
handleBulkDeletePermanently, handleEmptyTrash, trashedCount, confirmDelete state)
into a focused useDeleteActions hook. Reduces App.tsx from 733 to 672 lines.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:28:19 +01:00
Luca Rossi
9891a29f7f test: extract useBulkActions hook and add unit tests (#192)
useBulkActions was embedded in App.tsx with zero test coverage. It handles
bulk archive, trash, and restore operations — all with partial-failure
semantics and toast messaging.

Extract to src/hooks/useBulkActions.ts and add 15 unit tests covering:
- Plural/singular toast messages ("2 notes archived" vs "1 note archived")
- Partial failures: only successful operations counted in toast
- All-failure case: no toast shown
- Empty array: no operations called, no toast

Risk mitigated: silent bugs in batch operations (wrong count in toast,
toast shown when nothing succeeded, partial failure not handled).

Co-authored-by: Test <test@test.com>
2026-03-12 03:19:32 +01:00
Luca Rossi
6c9b39c0f0 test: add useEditorSaveWithLinks tests, remove dead useDropdownKeyboard (#191)
Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-12 03:01:39 +01:00
11 changed files with 975 additions and 236 deletions

View File

@@ -104,26 +104,40 @@ else
echo "⏭️ [4/5] Playwright smoke tests — skipped (no tests/smoke/*.spec.ts)"
fi
# ── 5. CodeScene code health gate (≥9.2) ────────────────────────────────
# ── 5. CodeScene code health gate ────────────────────────────────────────
echo ""
echo "🏥 [5/5] CodeScene code health gate (≥9.2)..."
echo "🏥 [5/5] CodeScene code health gate (hotspot ≥9.2, average ≥8.8)..."
if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then
echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping"
else
THRESHOLD=9.2
SCORE=$(curl -sf \
HOTSPOT_THRESHOLD=9.2
AVERAGE_THRESHOLD=8.8
API_RESPONSE=$(curl -sf \
-H "Authorization: Bearer $CODESCENE_PAT" \
-H "Accept: application/json" \
"https://api.codescene.io/v2/projects/$CODESCENE_PROJECT_ID" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['hotspot_code_health']['now'])")
echo " Hotspot Code Health: $SCORE (threshold: $THRESHOLD)"
"https://api.codescene.io/v2/projects/$CODESCENE_PROJECT_ID")
HOTSPOT_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['hotspot_code_health']['now'])")
AVERAGE_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['average_code_health']['now'])")
echo " Hotspot Code Health: $HOTSPOT_SCORE (threshold: $HOTSPOT_THRESHOLD)"
echo " Average Code Health: $AVERAGE_SCORE (threshold: $AVERAGE_THRESHOLD)"
python3 -c "
score = float('$SCORE')
threshold = float('$THRESHOLD')
if score < threshold:
print(f' ❌ Code Health {score:.2f} below threshold {threshold}')
hotspot = float('$HOTSPOT_SCORE')
average = float('$AVERAGE_SCORE')
ht = float('$HOTSPOT_THRESHOLD')
at = float('$AVERAGE_THRESHOLD')
failed = False
if hotspot < ht:
print(f' ❌ Hotspot Code Health {hotspot:.2f} below threshold {ht}')
failed = True
else:
print(f' ✅ Hotspot Code Health {hotspot:.2f} ≥ {ht}')
if average < at:
print(f' ❌ Average Code Health {average:.2f} below threshold {at}')
failed = True
else:
print(f' ✅ Average Code Health {average:.2f} ≥ {at}')
if failed:
exit(1)
print(f' ✅ Code Health {score:.2f} ≥ {threshold}')
"
fi

View File

@@ -26,7 +26,6 @@ import { useDialogs } from './hooks/useDialogs'
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
import { useGitHistory } from './hooks/useGitHistory'
import { useUpdater, restartApp } from './hooks/useUpdater'
import { useNavigationHistory } from './hooks/useNavigationHistory'
import { useAutoSync } from './hooks/useAutoSync'
import { useConflictResolver } from './hooks/useConflictResolver'
import { useIndexing } from './hooks/useIndexing'
@@ -36,8 +35,11 @@ import { useBuildNumber } from './hooks/useBuildNumber'
import { useOnboarding } from './hooks/useOnboarding'
import { useThemeManager } from './hooks/useThemeManager'
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
import { useNavigationGestures } from './hooks/useNavigationGestures'
import { useAppNavigation } from './hooks/useAppNavigation'
import { useAiActivity } from './hooks/useAiActivity'
import { useBulkActions } from './hooks/useBulkActions'
import { useDeleteActions } from './hooks/useDeleteActions'
import { useLayoutPanels } from './hooks/useLayoutPanels'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
import { UpdateBanner } from './components/UpdateBanner'
@@ -61,51 +63,6 @@ declare global {
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
function useBulkActions(
entryActions: { handleArchiveNote: (path: string) => Promise<void>; handleTrashNote: (path: string) => Promise<void>; handleRestoreNote: (path: string) => Promise<void> },
setToastMessage: (msg: string | null) => void,
) {
const handleBulkArchive = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleArchiveNote(path); ok++ }
catch { /* error toast already shown by flushBeforeAction */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} archived`)
}, [entryActions, setToastMessage])
const handleBulkTrash = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleTrashNote(path); ok++ }
catch { /* error toast already shown by flushBeforeAction */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} moved to trash`)
}, [entryActions, setToastMessage])
const handleBulkRestore = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleRestoreNote(path); ok++ }
catch { /* skip — error toast already shown */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} restored`)
}, [entryActions, setToastMessage])
return { handleBulkArchive, handleBulkTrash, handleBulkRestore }
}
function useLayoutPanels() {
const [sidebarWidth, setSidebarWidth] = useState(250)
const [noteListWidth, setNoteListWidth] = useState(300)
const [inspectorWidth, setInspectorWidth] = useState(280)
const [inspectorCollapsed, setInspectorCollapsed] = useState(false)
const handleSidebarResize = useCallback((delta: number) => setSidebarWidth((w) => Math.max(150, Math.min(400, w + delta))), [])
const handleNoteListResize = useCallback((delta: number) => setNoteListWidth((w) => Math.max(200, Math.min(500, w + delta))), [])
const handleInspectorResize = useCallback((delta: number) => setInspectorWidth((w) => Math.max(200, Math.min(500, w - delta))), [])
return { sidebarWidth, noteListWidth, inspectorWidth, inspectorCollapsed, setInspectorCollapsed, handleSidebarResize, handleNoteListResize, handleInspectorResize }
}
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
function App() {
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
@@ -211,53 +168,13 @@ function App() {
})
}, [vault.entries]) // eslint-disable-line react-hooks/exhaustive-deps -- notes.setTabs is stable (useState setter)
const navHistory = useNavigationHistory()
// Push to navigation history whenever the active tab changes (user-initiated)
const navFromHistoryRef = useRef(false)
useEffect(() => {
if (notes.activeTabPath && !navFromHistoryRef.current) {
navHistory.push(notes.activeTabPath)
}
navFromHistoryRef.current = false
}, [notes.activeTabPath]) // eslint-disable-line react-hooks/exhaustive-deps -- navHistory.push is stable
const isEntryExists = useCallback((path: string) => vault.entries.some(e => e.path === path), [vault.entries])
const handleGoBack = useCallback(() => {
const target = navHistory.goBack(isEntryExists)
if (target) {
navFromHistoryRef.current = true
if (notes.tabs.some(t => t.entry.path === target)) {
notes.handleSwitchTab(target)
} else {
const entry = vault.entries.find(e => e.path === target)
if (entry) notes.handleSelectNote(entry)
}
}
}, [navHistory, isEntryExists, vault.entries, notes])
const handleGoForward = useCallback(() => {
const target = navHistory.goForward(isEntryExists)
if (target) {
navFromHistoryRef.current = true
if (notes.tabs.some(t => t.entry.path === target)) {
notes.handleSwitchTab(target)
} else {
const entry = vault.entries.find(e => e.path === target)
if (entry) notes.handleSelectNote(entry)
}
}
}, [navHistory, isEntryExists, vault.entries, notes])
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
// O(1) path lookup map — rebuilt only when vault.entries changes
const entriesByPath = useMemo(() => {
const map = new Map<string, VaultEntry>()
for (const e of vault.entries) map.set(e.path, e)
return map
}, [vault.entries])
const { handleGoBack, handleGoForward, canGoBack, canGoForward, entriesByPath } = useAppNavigation({
entries: vault.entries,
tabs: notes.tabs,
activeTabPath: notes.activeTabPath,
onSelectNote: notes.handleSelectNote,
onSwitchTab: notes.handleSwitchTab,
})
// MCP UI bridge: react to AI-driven open/highlight/vault-change events
const openNoteByPath = useCallback((path: string) => {
@@ -399,75 +316,13 @@ function App() {
onBeforeAction: flushBeforeAction,
})
const deleteNoteFromDisk = useCallback(async (path: string) => {
try {
if (isTauri()) await invoke('delete_note', { path })
else await mockInvoke('delete_note', { path })
notes.handleCloseTab(path)
vault.removeEntry(path)
return true
} catch (e) {
setToastMessage(`Failed to delete note: ${e}`)
return false
}
}, [notes, vault, setToastMessage])
// Confirmation dialog state for permanent delete
const [confirmDelete, setConfirmDelete] = useState<{ title: string; message: string; confirmLabel?: string; onConfirm: () => void } | null>(null)
const handleDeleteNote = useCallback(async (path: string) => {
setConfirmDelete({
title: 'Delete permanently?',
message: 'This note will be permanently deleted. This cannot be undone.',
onConfirm: async () => {
setConfirmDelete(null)
const ok = await deleteNoteFromDisk(path)
if (ok) setToastMessage('Note permanently deleted')
},
})
}, [deleteNoteFromDisk, setToastMessage])
const handleBulkDeletePermanently = useCallback((paths: string[]) => {
const count = paths.length
setConfirmDelete({
title: `Delete ${count} ${count === 1 ? 'note' : 'notes'} permanently?`,
message: `${count === 1 ? 'This note' : `These ${count} notes`} will be permanently deleted. This cannot be undone.`,
onConfirm: async () => {
setConfirmDelete(null)
let ok = 0
for (const path of paths) {
if (await deleteNoteFromDisk(path)) ok++
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} permanently deleted`)
},
})
}, [deleteNoteFromDisk, setToastMessage])
const trashedCount = useMemo(() => vault.entries.filter(e => e.trashed).length, [vault.entries])
const handleEmptyTrash = useCallback(() => {
if (trashedCount === 0) return
setConfirmDelete({
title: 'Empty Trash?',
message: `Permanently delete all ${trashedCount} trashed ${trashedCount === 1 ? 'note' : 'notes'}? This cannot be undone.`,
confirmLabel: 'Empty Trash',
onConfirm: async () => {
setConfirmDelete(null)
try {
const tauriInvoke = isTauri() ? invoke : mockInvoke
const deleted = await tauriInvoke<string[]>('empty_trash', { vaultPath: resolvedPath })
// Close tabs and remove entries for deleted notes
for (const path of deleted) {
notes.handleCloseTab(path)
vault.removeEntry(path)
}
setToastMessage(`${deleted.length} note${deleted.length !== 1 ? 's' : ''} permanently deleted`)
} catch (e) {
setToastMessage(`Failed to empty trash: ${e}`)
}
},
})
}, [trashedCount, resolvedPath, notes, vault, setToastMessage])
const deleteActions = useDeleteActions({
vaultPath: resolvedPath,
entries: vault.entries,
handleCloseTab: notes.handleCloseTab,
removeEntry: vault.removeEntry,
setToastMessage,
})
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
@@ -567,7 +422,7 @@ function App() {
onSwitchTab: notes.handleSwitchTab, onReplaceActiveTab: notes.handleReplaceActiveTab,
onSelectNote: notes.handleSelectNote,
onGoBack: handleGoBack, onGoForward: handleGoForward,
canGoBack: navHistory.canGoBack, canGoForward: navHistory.canGoForward,
canGoBack: canGoBack, canGoForward: canGoForward,
themes: themeManager.themes, activeThemeId: themeManager.activeThemeId,
onSwitchTheme: themeManager.switchTheme,
onCreateTheme: async () => {
@@ -594,8 +449,8 @@ function App() {
vaultCount: vaultSwitcher.allVaults.length,
mcpStatus,
onInstallMcp: installMcp,
onEmptyTrash: handleEmptyTrash,
trashedCount,
onEmptyTrash: deleteActions.handleEmptyTrash,
trashedCount: deleteActions.trashedCount,
onReopenClosedTab: notes.handleReopenClosedTab,
onReindexVault: indexing.triggerFullReindex,
onReloadVault: vault.reloadVault,
@@ -663,7 +518,7 @@ function App() {
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={handleBulkDeletePermanently} onEmptyTrash={handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -700,7 +555,7 @@ function App() {
noteListFilter={aiNoteListFilter}
onTrashNote={entryActions.handleTrashNote}
onRestoreNote={entryActions.handleRestoreNote}
onDeleteNote={handleDeleteNote}
onDeleteNote={deleteActions.handleDeleteNote}
onArchiveNote={entryActions.handleArchiveNote}
onUnarchiveNote={entryActions.handleUnarchiveNote}
onRenameTab={handleRenameTab}
@@ -709,8 +564,8 @@ function App() {
onTitleSync={handleTitleSync}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={navHistory.canGoBack}
canGoForward={navHistory.canGoForward}
canGoBack={canGoBack}
canGoForward={canGoForward}
onGoBack={handleGoBack}
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
@@ -749,14 +604,14 @@ function App() {
onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }}
onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })}
/>
{confirmDelete && (
{deleteActions.confirmDelete && (
<ConfirmDeleteDialog
open={true}
title={confirmDelete.title}
message={confirmDelete.message}
confirmLabel={confirmDelete.confirmLabel}
onConfirm={confirmDelete.onConfirm}
onCancel={() => setConfirmDelete(null)}
title={deleteActions.confirmDelete.title}
message={deleteActions.confirmDelete.message}
confirmLabel={deleteActions.confirmDelete.confirmLabel}
onConfirm={deleteActions.confirmDelete.onConfirm}
onCancel={() => deleteActions.setConfirmDelete(null)}
/>
)}
</div>

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useAppNavigation } from './useAppNavigation'
import type { VaultEntry } from '../types'
function makeEntry(path: string): VaultEntry {
return { path, filename: path.split('/').pop()!, title: path, isA: null, aliases: [] } as VaultEntry
}
function makeTab(entry: VaultEntry) {
return { entry, content: '' }
}
describe('useAppNavigation', () => {
let onSelectNote: ReturnType<typeof vi.fn>
let onSwitchTab: ReturnType<typeof vi.fn>
beforeEach(() => {
onSelectNote = vi.fn()
onSwitchTab = vi.fn()
})
function renderNav(overrides: {
entries?: VaultEntry[]
tabs?: Array<{ entry: VaultEntry; content: string }>
activeTabPath?: string | null
} = {}) {
const entries = overrides.entries ?? [makeEntry('/a.md'), makeEntry('/b.md'), makeEntry('/c.md')]
const tabs = overrides.tabs ?? []
const activeTabPath = overrides.activeTabPath ?? null
return renderHook(() =>
useAppNavigation({ entries, tabs, activeTabPath, onSelectNote, onSwitchTab }),
)
}
// --- entriesByPath ---
describe('entriesByPath', () => {
it('builds a Map from entries for O(1) lookup', () => {
const entries = [makeEntry('/a.md'), makeEntry('/b.md')]
const { result } = renderNav({ entries })
expect(result.current.entriesByPath.get('/a.md')).toBe(entries[0])
expect(result.current.entriesByPath.get('/b.md')).toBe(entries[1])
expect(result.current.entriesByPath.get('/missing.md')).toBeUndefined()
})
})
// --- canGoBack / canGoForward initial state ---
describe('initial state', () => {
it('starts with canGoBack=false and canGoForward=false', () => {
const { result } = renderNav()
expect(result.current.canGoBack).toBe(false)
expect(result.current.canGoForward).toBe(false)
})
})
// --- navigation history integration ---
describe('navigation via activeTabPath changes', () => {
it('pushes to history when activeTabPath changes, enabling goBack', () => {
const entries = [makeEntry('/a.md'), makeEntry('/b.md')]
const tabA = makeTab(entries[0])
const tabB = makeTab(entries[1])
const { result, rerender } = renderHook(
({ activeTabPath, tabs }) =>
useAppNavigation({ entries, tabs, activeTabPath, onSelectNote, onSwitchTab }),
{ initialProps: { activeTabPath: '/a.md' as string | null, tabs: [tabA] } },
)
// Navigate to /b.md
rerender({ activeTabPath: '/b.md', tabs: [tabA, tabB] })
expect(result.current.canGoBack).toBe(true)
expect(result.current.canGoForward).toBe(false)
})
it('handleGoBack switches to the tab if it is open', () => {
const entries = [makeEntry('/a.md'), makeEntry('/b.md')]
const tabA = makeTab(entries[0])
const tabB = makeTab(entries[1])
const { result, rerender } = renderHook(
({ activeTabPath, tabs }) =>
useAppNavigation({ entries, tabs, activeTabPath, onSelectNote, onSwitchTab }),
{ initialProps: { activeTabPath: '/a.md' as string | null, tabs: [tabA, tabB] } },
)
rerender({ activeTabPath: '/b.md', tabs: [tabA, tabB] })
act(() => { result.current.handleGoBack() })
expect(onSwitchTab).toHaveBeenCalledWith('/a.md')
})
it('handleGoBack opens entry via onSelectNote if not in tabs', () => {
const entries = [makeEntry('/a.md'), makeEntry('/b.md')]
const tabB = makeTab(entries[1])
const { result, rerender } = renderHook(
({ activeTabPath, tabs }) =>
useAppNavigation({ entries, tabs, activeTabPath, onSelectNote, onSwitchTab }),
{ initialProps: { activeTabPath: '/a.md' as string | null, tabs: [tabB] } },
)
rerender({ activeTabPath: '/b.md', tabs: [tabB] })
act(() => { result.current.handleGoBack() })
expect(onSelectNote).toHaveBeenCalledWith(entries[0])
})
it('handleGoForward works after going back', () => {
const entries = [makeEntry('/a.md'), makeEntry('/b.md')]
const tabA = makeTab(entries[0])
const tabB = makeTab(entries[1])
const { result, rerender } = renderHook(
({ activeTabPath, tabs }) =>
useAppNavigation({ entries, tabs, activeTabPath, onSelectNote, onSwitchTab }),
{ initialProps: { activeTabPath: '/a.md' as string | null, tabs: [tabA, tabB] } },
)
rerender({ activeTabPath: '/b.md', tabs: [tabA, tabB] })
act(() => { result.current.handleGoBack() })
expect(result.current.canGoForward).toBe(true)
act(() => { result.current.handleGoForward() })
expect(onSwitchTab).toHaveBeenCalledWith('/b.md')
})
})
})

View File

@@ -0,0 +1,89 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useNavigationHistory } from './useNavigationHistory'
import { useNavigationGestures } from './useNavigationGestures'
import type { VaultEntry } from '../types'
interface TabLike {
entry: { path: string }
}
interface UseAppNavigationParams {
entries: VaultEntry[]
tabs: TabLike[]
activeTabPath: string | null
onSelectNote: (entry: VaultEntry) => void
onSwitchTab: (path: string) => void
}
/**
* Encapsulates browser-style back/forward navigation for the app:
* - Navigation history (push on tab change, back/forward traversal)
* - Mouse button & trackpad gesture bindings
* - O(1) path→entry lookup map
*/
export function useAppNavigation({
entries,
tabs,
activeTabPath,
onSelectNote,
onSwitchTab,
}: UseAppNavigationParams) {
const navHistory = useNavigationHistory()
// Push to navigation history whenever the active tab changes (user-initiated)
const navFromHistoryRef = useRef(false)
useEffect(() => {
if (activeTabPath && !navFromHistoryRef.current) {
navHistory.push(activeTabPath)
}
navFromHistoryRef.current = false
}, [activeTabPath]) // eslint-disable-line react-hooks/exhaustive-deps -- navHistory.push is stable
const isEntryExists = useCallback(
(path: string) => entries.some(e => e.path === path),
[entries],
)
const handleGoBack = useCallback(() => {
const target = navHistory.goBack(isEntryExists)
if (target) {
navFromHistoryRef.current = true
if (tabs.some(t => t.entry.path === target)) {
onSwitchTab(target)
} else {
const entry = entries.find(e => e.path === target)
if (entry) onSelectNote(entry)
}
}
}, [navHistory, isEntryExists, entries, tabs, onSelectNote, onSwitchTab])
const handleGoForward = useCallback(() => {
const target = navHistory.goForward(isEntryExists)
if (target) {
navFromHistoryRef.current = true
if (tabs.some(t => t.entry.path === target)) {
onSwitchTab(target)
} else {
const entry = entries.find(e => e.path === target)
if (entry) onSelectNote(entry)
}
}
}, [navHistory, isEntryExists, entries, tabs, onSelectNote, onSwitchTab])
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
// O(1) path lookup map — rebuilt only when entries change
const entriesByPath = useMemo(() => {
const map = new Map<string, VaultEntry>()
for (const e of entries) map.set(e.path, e)
return map
}, [entries])
return {
handleGoBack,
handleGoForward,
canGoBack: navHistory.canGoBack,
canGoForward: navHistory.canGoForward,
entriesByPath,
}
}

View File

@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useBulkActions } from './useBulkActions'
describe('useBulkActions', () => {
let handleArchiveNote: ReturnType<typeof vi.fn>
let handleTrashNote: ReturnType<typeof vi.fn>
let handleRestoreNote: ReturnType<typeof vi.fn>
let setToastMessage: ReturnType<typeof vi.fn>
beforeEach(() => {
handleArchiveNote = vi.fn().mockResolvedValue(undefined)
handleTrashNote = vi.fn().mockResolvedValue(undefined)
handleRestoreNote = vi.fn().mockResolvedValue(undefined)
setToastMessage = vi.fn()
})
function renderBulkActions() {
return renderHook(() =>
useBulkActions(
{ handleArchiveNote, handleTrashNote, handleRestoreNote },
setToastMessage,
),
)
}
// --- handleBulkArchive ---
describe('handleBulkArchive', () => {
it('archives each path and shows plural toast for multiple notes', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkArchive(['/vault/a.md', '/vault/b.md'])
})
expect(handleArchiveNote).toHaveBeenCalledTimes(2)
expect(handleArchiveNote).toHaveBeenCalledWith('/vault/a.md')
expect(handleArchiveNote).toHaveBeenCalledWith('/vault/b.md')
expect(setToastMessage).toHaveBeenCalledWith('2 notes archived')
})
it('shows singular toast when one note archived', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkArchive(['/vault/a.md'])
})
expect(setToastMessage).toHaveBeenCalledWith('1 note archived')
})
it('does not show toast when empty array given', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkArchive([])
})
expect(handleArchiveNote).not.toHaveBeenCalled()
expect(setToastMessage).not.toHaveBeenCalled()
})
it('skips failed paths and only counts successes in toast', async () => {
handleArchiveNote
.mockResolvedValueOnce(undefined) // /vault/a.md succeeds
.mockRejectedValueOnce(new Error('fail')) // /vault/b.md fails
.mockResolvedValueOnce(undefined) // /vault/c.md succeeds
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkArchive(['/vault/a.md', '/vault/b.md', '/vault/c.md'])
})
expect(handleArchiveNote).toHaveBeenCalledTimes(3)
expect(setToastMessage).toHaveBeenCalledWith('2 notes archived')
})
it('shows no toast when all paths fail', async () => {
handleArchiveNote.mockRejectedValue(new Error('fail'))
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkArchive(['/vault/a.md', '/vault/b.md'])
})
expect(setToastMessage).not.toHaveBeenCalled()
})
})
// --- handleBulkTrash ---
describe('handleBulkTrash', () => {
it('trashes each path and shows plural toast', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkTrash(['/vault/a.md', '/vault/b.md'])
})
expect(handleTrashNote).toHaveBeenCalledTimes(2)
expect(setToastMessage).toHaveBeenCalledWith('2 notes moved to trash')
})
it('shows singular toast when one note trashed', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkTrash(['/vault/a.md'])
})
expect(setToastMessage).toHaveBeenCalledWith('1 note moved to trash')
})
it('does not show toast when empty array given', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkTrash([])
})
expect(setToastMessage).not.toHaveBeenCalled()
})
it('skips failed paths and counts only successes', async () => {
handleTrashNote
.mockRejectedValueOnce(new Error('fail'))
.mockResolvedValueOnce(undefined)
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkTrash(['/vault/a.md', '/vault/b.md'])
})
expect(setToastMessage).toHaveBeenCalledWith('1 note moved to trash')
})
it('shows no toast when all fail', async () => {
handleTrashNote.mockRejectedValue(new Error('fail'))
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkTrash(['/vault/a.md'])
})
expect(setToastMessage).not.toHaveBeenCalled()
})
})
// --- handleBulkRestore ---
describe('handleBulkRestore', () => {
it('restores each path and shows plural toast', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkRestore(['/vault/a.md', '/vault/b.md', '/vault/c.md'])
})
expect(handleRestoreNote).toHaveBeenCalledTimes(3)
expect(setToastMessage).toHaveBeenCalledWith('3 notes restored')
})
it('shows singular toast when one note restored', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkRestore(['/vault/only.md'])
})
expect(setToastMessage).toHaveBeenCalledWith('1 note restored')
})
it('does not show toast when empty array given', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkRestore([])
})
expect(setToastMessage).not.toHaveBeenCalled()
})
it('partial failure: counts only successful restores in toast', async () => {
handleRestoreNote
.mockResolvedValueOnce(undefined) // /vault/a.md ok
.mockRejectedValueOnce(new Error('not found')) // /vault/b.md fails
.mockResolvedValueOnce(undefined) // /vault/c.md ok
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkRestore(['/vault/a.md', '/vault/b.md', '/vault/c.md'])
})
expect(handleRestoreNote).toHaveBeenCalledTimes(3)
expect(setToastMessage).toHaveBeenCalledWith('2 notes restored')
})
it('shows no toast when all restores fail', async () => {
handleRestoreNote.mockRejectedValue(new Error('fail'))
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkRestore(['/vault/a.md', '/vault/b.md'])
})
expect(setToastMessage).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,41 @@
import { useCallback } from 'react'
interface BulkEntryActions {
handleArchiveNote: (path: string) => Promise<void>
handleTrashNote: (path: string) => Promise<void>
handleRestoreNote: (path: string) => Promise<void>
}
export function useBulkActions(
entryActions: BulkEntryActions,
setToastMessage: (msg: string | null) => void,
) {
const handleBulkArchive = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleArchiveNote(path); ok++ }
catch { /* error toast already shown by flushBeforeAction */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} archived`)
}, [entryActions, setToastMessage])
const handleBulkTrash = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleTrashNote(path); ok++ }
catch { /* error toast already shown by flushBeforeAction */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} moved to trash`)
}, [entryActions, setToastMessage])
const handleBulkRestore = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleRestoreNote(path); ok++ }
catch { /* skip — error toast already shown */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} restored`)
}, [entryActions, setToastMessage])
return { handleBulkArchive, handleBulkTrash, handleBulkRestore }
}

View File

@@ -0,0 +1,222 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useDeleteActions } from './useDeleteActions'
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn(),
}))
const { mockInvoke } = await import('../mock-tauri')
const mockInvokeFn = mockInvoke as ReturnType<typeof vi.fn>
function makeEntry(path: string, trashed = false) {
return { path, trashed } as { path: string; trashed: boolean }
}
describe('useDeleteActions', () => {
let handleCloseTab: ReturnType<typeof vi.fn>
let removeEntry: ReturnType<typeof vi.fn>
let setToastMessage: ReturnType<typeof vi.fn>
beforeEach(() => {
handleCloseTab = vi.fn()
removeEntry = vi.fn()
setToastMessage = vi.fn()
mockInvokeFn.mockReset()
})
function renderDeleteActions(entries = [makeEntry('/vault/a.md'), makeEntry('/vault/t.md', true)]) {
return renderHook(() =>
useDeleteActions({
vaultPath: '/vault',
entries,
handleCloseTab,
removeEntry,
setToastMessage,
}),
)
}
// --- trashedCount ---
describe('trashedCount', () => {
it('counts trashed entries', () => {
const { result } = renderDeleteActions([
makeEntry('/vault/a.md'),
makeEntry('/vault/b.md', true),
makeEntry('/vault/c.md', true),
])
expect(result.current.trashedCount).toBe(2)
})
it('returns 0 when no trashed entries', () => {
const { result } = renderDeleteActions([makeEntry('/vault/a.md')])
expect(result.current.trashedCount).toBe(0)
})
})
// --- deleteNoteFromDisk ---
describe('deleteNoteFromDisk', () => {
it('invokes delete_note, closes tab, removes entry, returns true', async () => {
mockInvokeFn.mockResolvedValue(undefined)
const { result } = renderDeleteActions()
let ok: boolean | undefined
await act(async () => {
ok = await result.current.deleteNoteFromDisk('/vault/a.md')
})
expect(ok).toBe(true)
expect(mockInvokeFn).toHaveBeenCalledWith('delete_note', { path: '/vault/a.md' })
expect(handleCloseTab).toHaveBeenCalledWith('/vault/a.md')
expect(removeEntry).toHaveBeenCalledWith('/vault/a.md')
})
it('shows toast and returns false on failure', async () => {
mockInvokeFn.mockRejectedValue(new Error('disk full'))
const { result } = renderDeleteActions()
let ok: boolean | undefined
await act(async () => {
ok = await result.current.deleteNoteFromDisk('/vault/a.md')
})
expect(ok).toBe(false)
expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to delete'))
})
})
// --- handleDeleteNote ---
describe('handleDeleteNote', () => {
it('sets confirmDelete dialog state', async () => {
const { result } = renderDeleteActions()
await act(async () => {
await result.current.handleDeleteNote('/vault/a.md')
})
expect(result.current.confirmDelete).not.toBeNull()
expect(result.current.confirmDelete?.title).toBe('Delete permanently?')
})
it('onConfirm deletes the note and clears dialog', async () => {
mockInvokeFn.mockResolvedValue(undefined)
const { result } = renderDeleteActions()
await act(async () => {
await result.current.handleDeleteNote('/vault/a.md')
})
await act(async () => {
await result.current.confirmDelete!.onConfirm()
})
expect(result.current.confirmDelete).toBeNull()
expect(mockInvokeFn).toHaveBeenCalledWith('delete_note', { path: '/vault/a.md' })
expect(setToastMessage).toHaveBeenCalledWith('Note permanently deleted')
})
})
// --- handleBulkDeletePermanently ---
describe('handleBulkDeletePermanently', () => {
it('sets confirmDelete with correct plural title', () => {
const { result } = renderDeleteActions()
act(() => {
result.current.handleBulkDeletePermanently(['/vault/a.md', '/vault/b.md'])
})
expect(result.current.confirmDelete?.title).toBe('Delete 2 notes permanently?')
})
it('sets confirmDelete with singular title for one note', () => {
const { result } = renderDeleteActions()
act(() => {
result.current.handleBulkDeletePermanently(['/vault/a.md'])
})
expect(result.current.confirmDelete?.title).toBe('Delete 1 note permanently?')
})
it('onConfirm deletes all paths and shows toast', async () => {
mockInvokeFn.mockResolvedValue(undefined)
const { result } = renderDeleteActions()
act(() => {
result.current.handleBulkDeletePermanently(['/vault/a.md', '/vault/b.md'])
})
await act(async () => {
await result.current.confirmDelete!.onConfirm()
})
expect(result.current.confirmDelete).toBeNull()
expect(mockInvokeFn).toHaveBeenCalledTimes(2)
expect(setToastMessage).toHaveBeenCalledWith('2 notes permanently deleted')
})
})
// --- handleEmptyTrash ---
describe('handleEmptyTrash', () => {
it('does nothing when trashedCount is 0', () => {
const { result } = renderDeleteActions([makeEntry('/vault/a.md')])
act(() => {
result.current.handleEmptyTrash()
})
expect(result.current.confirmDelete).toBeNull()
})
it('sets confirmDelete with trash count in message', () => {
const { result } = renderDeleteActions([
makeEntry('/vault/a.md'),
makeEntry('/vault/t1.md', true),
makeEntry('/vault/t2.md', true),
])
act(() => {
result.current.handleEmptyTrash()
})
expect(result.current.confirmDelete?.title).toBe('Empty Trash?')
expect(result.current.confirmDelete?.confirmLabel).toBe('Empty Trash')
})
it('onConfirm invokes empty_trash, closes tabs, removes entries', async () => {
mockInvokeFn.mockResolvedValue(['/vault/t1.md', '/vault/t2.md'])
const { result } = renderDeleteActions([
makeEntry('/vault/a.md'),
makeEntry('/vault/t1.md', true),
makeEntry('/vault/t2.md', true),
])
act(() => {
result.current.handleEmptyTrash()
})
await act(async () => {
await result.current.confirmDelete!.onConfirm()
})
expect(result.current.confirmDelete).toBeNull()
expect(mockInvokeFn).toHaveBeenCalledWith('empty_trash', { vaultPath: '/vault' })
expect(handleCloseTab).toHaveBeenCalledWith('/vault/t1.md')
expect(handleCloseTab).toHaveBeenCalledWith('/vault/t2.md')
expect(removeEntry).toHaveBeenCalledWith('/vault/t1.md')
expect(removeEntry).toHaveBeenCalledWith('/vault/t2.md')
expect(setToastMessage).toHaveBeenCalledWith('2 notes permanently deleted')
})
it('shows error toast when empty_trash fails', async () => {
mockInvokeFn.mockRejectedValue(new Error('oops'))
const { result } = renderDeleteActions([makeEntry('/vault/t.md', true)])
act(() => {
result.current.handleEmptyTrash()
})
await act(async () => {
await result.current.confirmDelete!.onConfirm()
})
expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to empty trash'))
})
})
// --- setConfirmDelete ---
describe('setConfirmDelete', () => {
it('can clear confirmDelete via setConfirmDelete(null)', async () => {
const { result } = renderDeleteActions()
await act(async () => {
await result.current.handleDeleteNote('/vault/a.md')
})
expect(result.current.confirmDelete).not.toBeNull()
act(() => {
result.current.setConfirmDelete(null)
})
expect(result.current.confirmDelete).toBeNull()
})
})
})

View File

@@ -0,0 +1,105 @@
import { useCallback, useMemo, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
interface ConfirmDeleteState {
title: string
message: string
confirmLabel?: string
onConfirm: () => void
}
interface UseDeleteActionsInput {
vaultPath: string
entries: VaultEntry[]
handleCloseTab: (path: string) => void
removeEntry: (path: string) => void
setToastMessage: (msg: string | null) => void
}
export function useDeleteActions({
vaultPath,
entries,
handleCloseTab,
removeEntry,
setToastMessage,
}: UseDeleteActionsInput) {
const [confirmDelete, setConfirmDelete] = useState<ConfirmDeleteState | null>(null)
const trashedCount = useMemo(() => entries.filter(e => e.trashed).length, [entries])
const deleteNoteFromDisk = useCallback(async (path: string) => {
try {
if (isTauri()) await invoke('delete_note', { path })
else await mockInvoke('delete_note', { path })
handleCloseTab(path)
removeEntry(path)
return true
} catch (e) {
setToastMessage(`Failed to delete note: ${e}`)
return false
}
}, [handleCloseTab, removeEntry, setToastMessage])
const handleDeleteNote = useCallback(async (path: string) => {
setConfirmDelete({
title: 'Delete permanently?',
message: 'This note will be permanently deleted. This cannot be undone.',
onConfirm: async () => {
setConfirmDelete(null)
const ok = await deleteNoteFromDisk(path)
if (ok) setToastMessage('Note permanently deleted')
},
})
}, [deleteNoteFromDisk, setToastMessage])
const handleBulkDeletePermanently = useCallback((paths: string[]) => {
const count = paths.length
setConfirmDelete({
title: `Delete ${count} ${count === 1 ? 'note' : 'notes'} permanently?`,
message: `${count === 1 ? 'This note' : `These ${count} notes`} will be permanently deleted. This cannot be undone.`,
onConfirm: async () => {
setConfirmDelete(null)
let ok = 0
for (const path of paths) {
if (await deleteNoteFromDisk(path)) ok++
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} permanently deleted`)
},
})
}, [deleteNoteFromDisk, setToastMessage])
const handleEmptyTrash = useCallback(() => {
if (trashedCount === 0) return
setConfirmDelete({
title: 'Empty Trash?',
message: `Permanently delete all ${trashedCount} trashed ${trashedCount === 1 ? 'note' : 'notes'}? This cannot be undone.`,
confirmLabel: 'Empty Trash',
onConfirm: async () => {
setConfirmDelete(null)
try {
const tauriInvoke = isTauri() ? invoke : mockInvoke
const deleted = await tauriInvoke<string[]>('empty_trash', { vaultPath })
for (const path of deleted) {
handleCloseTab(path)
removeEntry(path)
}
setToastMessage(`${deleted.length} note${deleted.length !== 1 ? 's' : ''} permanently deleted`)
} catch (e) {
setToastMessage(`Failed to empty trash: ${e}`)
}
},
})
}, [trashedCount, vaultPath, handleCloseTab, removeEntry, setToastMessage])
return {
confirmDelete,
setConfirmDelete,
trashedCount,
deleteNoteFromDisk,
handleDeleteNote,
handleBulkDeletePermanently,
handleEmptyTrash,
}
}

View File

@@ -1,48 +0,0 @@
import { useCallback, useRef } from 'react'
function resolveEnterTarget(
highlightIndex: number, allFiltered: string[], showCreateOption: boolean, query: string,
): string | null {
if (highlightIndex >= 0 && highlightIndex < allFiltered.length) return allFiltered[highlightIndex]
if (showCreateOption && highlightIndex === allFiltered.length) return query.trim()
if (query.trim()) return query.trim()
return null
}
export function useDropdownKeyboard({
highlightIndex, setHighlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel,
}: {
highlightIndex: number; setHighlightIndex: (i: number) => void; totalOptions: number
allFiltered: string[]; showCreateOption: boolean; query: string
onSave: (v: string) => void; onCancel: () => void
}) {
const listRef = useRef<HTMLDivElement>(null)
const scrollHighlightedIntoView = useCallback((index: number) => {
const items = listRef.current?.querySelectorAll('[data-testid^="status-option-"], [data-testid="status-create-option"]')
items?.[index]?.scrollIntoView({ block: 'nearest' })
}, [])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
const next = e.key === 'ArrowDown'
? (highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0)
: (highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1)
setHighlightIndex(next)
scrollHighlightedIntoView(next)
} else if (e.key === 'Enter') {
e.preventDefault()
const target = resolveEnterTarget(highlightIndex, allFiltered, showCreateOption, query)
if (target) onSave(target)
} else if (e.key === 'Escape') {
e.preventDefault()
onCancel()
}
},
[highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, setHighlightIndex, scrollHighlightedIntoView],
)
return { listRef, handleKeyDown }
}

View File

@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
const mockHandleContentChange = vi.fn()
const mockHandleSave = vi.fn()
const mockSavePendingForPath = vi.fn()
vi.mock('./useEditorSave', () => ({
useEditorSave: vi.fn(() => ({
handleContentChange: mockHandleContentChange,
handleSave: mockHandleSave,
savePendingForPath: mockSavePendingForPath,
})),
}))
describe('useEditorSaveWithLinks', () => {
let updateEntry: Mock
let setTabs: Mock
let setToastMessage: Mock
let onAfterSave: Mock
beforeEach(() => {
updateEntry = vi.fn()
setTabs = vi.fn()
setToastMessage = vi.fn()
onAfterSave = vi.fn()
mockHandleContentChange.mockClear()
mockHandleSave.mockClear()
mockSavePendingForPath.mockClear()
})
function renderHookWithLinks() {
return renderHook(() =>
useEditorSaveWithLinks({
updateEntry,
setTabs,
setToastMessage,
onAfterSave,
}),
)
}
it('handleContentChange delegates to useEditorSave handleContentChange', () => {
const { result } = renderHookWithLinks()
act(() => {
result.current.handleContentChange('/note.md', 'no links here')
})
expect(mockHandleContentChange).toHaveBeenCalledWith('/note.md', 'no links here')
})
it('handleContentChange calls updateEntry with extracted outgoing links when links change', () => {
const { result } = renderHookWithLinks()
act(() => {
result.current.handleContentChange('/note.md', 'see [[PageA]] and [[PageB]]')
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
outgoingLinks: ['PageA', 'PageB'],
})
})
it('handleContentChange does NOT call updateEntry again when links have not changed', () => {
const { result } = renderHookWithLinks()
act(() => {
result.current.handleContentChange('/note.md', 'text [[Alpha]] more text')
})
expect(updateEntry).toHaveBeenCalledTimes(1)
// Same link, different surrounding text
act(() => {
result.current.handleContentChange('/note.md', 'different text [[Alpha]] still')
})
// updateEntry should NOT have been called again — links unchanged
expect(updateEntry).toHaveBeenCalledTimes(1)
})
it('handleContentChange calls updateEntry again when links change on subsequent edit', () => {
const { result } = renderHookWithLinks()
act(() => {
result.current.handleContentChange('/note.md', 'see [[Alpha]]')
})
expect(updateEntry).toHaveBeenCalledTimes(1)
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
outgoingLinks: ['Alpha'],
})
// Now links change
act(() => {
result.current.handleContentChange('/note.md', 'see [[Alpha]] and [[Beta]]')
})
expect(updateEntry).toHaveBeenCalledTimes(2)
expect(updateEntry).toHaveBeenLastCalledWith('/note.md', {
outgoingLinks: ['Alpha', 'Beta'],
})
})
it('handleContentChange calls updateEntry with empty links on first call with no links', () => {
const { result } = renderHookWithLinks()
// First call with no links — prevLinksKeyRef starts as '' and extracted key is also ''
// but since they're equal, updateEntry should NOT be called
act(() => {
result.current.handleContentChange('/note.md', 'plain text no links')
})
// The initial ref is '' and no-links key is also '' — no change
expect(updateEntry).not.toHaveBeenCalled()
})
it('handles pipe-separated wikilinks (display text syntax)', () => {
const { result } = renderHookWithLinks()
act(() => {
result.current.handleContentChange('/note.md', 'see [[Target|Display Text]]')
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
outgoingLinks: ['Target'],
})
})
it('spreads all properties from useEditorSave onto the return value', () => {
const { result } = renderHookWithLinks()
// handleSave and savePendingForPath should be passed through from the mock
expect(result.current.handleSave).toBeDefined()
expect(result.current.savePendingForPath).toBeDefined()
})
})

View File

@@ -0,0 +1,12 @@
import { useCallback, useState } from 'react'
export function useLayoutPanels() {
const [sidebarWidth, setSidebarWidth] = useState(250)
const [noteListWidth, setNoteListWidth] = useState(300)
const [inspectorWidth, setInspectorWidth] = useState(280)
const [inspectorCollapsed, setInspectorCollapsed] = useState(false)
const handleSidebarResize = useCallback((delta: number) => setSidebarWidth((w) => Math.max(150, Math.min(400, w + delta))), [])
const handleNoteListResize = useCallback((delta: number) => setNoteListWidth((w) => Math.max(200, Math.min(500, w + delta))), [])
const handleInspectorResize = useCallback((delta: number) => setInspectorWidth((w) => Math.max(200, Math.min(500, w - delta))), [])
return { sidebarWidth, noteListWidth, inspectorWidth, inspectorCollapsed, setInspectorCollapsed, handleSidebarResize, handleNoteListResize, handleInspectorResize }
}