Compare commits
6 Commits
v0.2026031
...
v0.2026031
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cb2194842 | ||
|
|
66090688f9 | ||
|
|
a15f36ec6a | ||
|
|
8137125569 | ||
|
|
9891a29f7f | ||
|
|
6c9b39c0f0 |
@@ -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
|
||||
|
||||
|
||||
207
src/App.tsx
207
src/App.tsx
@@ -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>
|
||||
|
||||
134
src/hooks/useAppNavigation.test.ts
Normal file
134
src/hooks/useAppNavigation.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
89
src/hooks/useAppNavigation.ts
Normal file
89
src/hooks/useAppNavigation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
180
src/hooks/useBulkActions.test.ts
Normal file
180
src/hooks/useBulkActions.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
41
src/hooks/useBulkActions.ts
Normal file
41
src/hooks/useBulkActions.ts
Normal 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 }
|
||||
}
|
||||
222
src/hooks/useDeleteActions.test.ts
Normal file
222
src/hooks/useDeleteActions.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
105
src/hooks/useDeleteActions.ts
Normal file
105
src/hooks/useDeleteActions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
135
src/hooks/useEditorSaveWithLinks.test.ts
Normal file
135
src/hooks/useEditorSaveWithLinks.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
12
src/hooks/useLayoutPanels.ts
Normal file
12
src/hooks/useLayoutPanels.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user