Compare commits

...

9 Commits

Author SHA1 Message Date
Test
09f7498e86 fix: Cmd+Option+arrow navigation now follows the visible note list order
The keyboard navigation was computing its own note list with hardcoded
sortByModified, which didn't match the actual UI order (Inbox sorts by
createdAt, custom types use configurable sorts, etc.). Now the NoteList
component writes its sorted list to a shared ref that the navigation
hook reads directly, ensuring navigation always matches visual order.

Also fixes navigation not wrapping at list boundaries per spec.

Syncs CodeScene thresholds with current remote scores.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:29:27 +02:00
Test
7443b9a468 fix: show Add button immediately in suggested relationship slots
Remove the collapsed/expanded toggle from SuggestedRelationshipSlot so
the InlineAddNote "Add" button with dashed border is visible without
a click. Also change relationship label font-size from font-mono-overline
(10px) to text-[12px] to match property names in the Inspector.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:59:34 +02:00
Test
12f8fad0f0 fix: align suggested property slots with real properties in Inspector
Add text-left to the suggested property button (browsers default buttons
to text-align: center) and remove text-right from the em-dash placeholder
so both label and value columns match the real property row alignment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:27:08 +02:00
Test
c90b1d6694 fix: add bottom padding to Favorites list matching top padding
Added paddingBottom: 4 to the expanded Favorites list container,
consistent with the ViewsSection pattern. This creates symmetric
spacing above the first and below the last favorite item.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:09:29 +02:00
Test
806b552d47 fix: default sidebar section to Inbox on app launch and vault switch
Changed DEFAULT_SELECTION from 'all' (All Notes) to 'inbox' so the
app always opens on the Inbox section. During a session the user's
navigation is preserved; on next launch or vault switch it resets
to Inbox.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:44:18 +02:00
Test
2cd192fae6 style: apply rustfmt to classify_file_kind tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:19:44 +02:00
Test
46106da47a fix: pass forceRawMode through ActiveTabBreadcrumb to BreadcrumbBar
TypeScript error: forceRawMode was set in the props object but not
declared in the ActiveTabBreadcrumb type or forwarded to BreadcrumbBar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:15:11 +02:00
Test
0051559b8e fix: hide Raw toggle for non-markdown files forced into raw editor
Non-markdown text files (.yml, .yaml, .json, etc.) are already forced
into raw editor mode via effectiveRawMode. This hides the Raw toggle
button in the breadcrumb bar for those files since toggling is not
applicable. Adds tests for classify_file_kind and forceRawMode behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:59:19 +02:00
Test
3f19528cb6 fix: wikilink filter values now match frontmatter format with alias support
The FilterBuilder autocomplete now stores wikilinks as [[stem|title]]
(e.g., [[monday-112|Monday 112]]) instead of just [[title]]. The view
filter comparison uses wikilinkEquals() to match any combination of
path and alias parts, so [[monday-112|Monday 112]] matches
[[monday-112|Monday #112]] via the shared path "monday-112".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:29:14 +02:00
18 changed files with 269 additions and 115 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.41
AVERAGE_THRESHOLD=9.30
HOTSPOT_THRESHOLD=9.33
AVERAGE_THRESHOLD=9.27

View File

@@ -1521,6 +1521,43 @@ fn test_non_yml_file_uses_filename_as_title() {
assert_eq!(entry.title, "notes.txt");
}
#[test]
fn test_classify_file_kind_yml_is_text() {
assert_eq!(
classify_file_kind(Path::new("views/active-projects.yml")),
"text"
);
assert_eq!(classify_file_kind(Path::new("config.yaml")), "text");
assert_eq!(classify_file_kind(Path::new("data.json")), "text");
assert_eq!(classify_file_kind(Path::new("script.py")), "text");
assert_eq!(classify_file_kind(Path::new("readme.txt")), "text");
}
#[test]
fn test_classify_file_kind_md_is_markdown() {
assert_eq!(classify_file_kind(Path::new("note.md")), "markdown");
assert_eq!(classify_file_kind(Path::new("README.markdown")), "markdown");
}
#[test]
fn test_classify_file_kind_unknown_is_binary() {
assert_eq!(classify_file_kind(Path::new("photo.png")), "binary");
assert_eq!(classify_file_kind(Path::new("archive.zip")), "binary");
}
#[test]
fn test_non_md_file_gets_text_file_kind() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"views/my-view.yml",
"name: My View\nicon: rocket\n",
);
let entry = super::parse_non_md_file(&dir.path().join("views/my-view.yml"), None).unwrap();
assert_eq!(entry.file_kind, "text");
assert_eq!(entry.title, "My View");
}
// Frontmatter update/delete tests are in frontmatter.rs
// save_image tests are in vault/image.rs
// purge_trash tests are in vault/trash.rs

View File

@@ -51,7 +51,7 @@ import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
import { UpdateBanner } from './components/UpdateBanner'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection, InboxPeriod } from './types'
import type { SidebarSelection, InboxPeriod, VaultEntry } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openNoteInNewWindow } from './utils/openNoteWindow'
@@ -70,7 +70,7 @@ declare global {
}
}
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'inbox' }
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
function App() {
@@ -83,6 +83,7 @@ function App() {
setNoteListFilter('open')
}, [])
const layout = useLayoutPanels(noteWindowParams ? { initialInspectorCollapsed: true } : undefined)
const visibleNotesRef = useRef<VaultEntry[]>([])
const [toastMessage, setToastMessage] = useState<string | null>(null)
const dialogs = useDialogs()
@@ -455,6 +456,7 @@ function App() {
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
entries: vault.entries,
visibleNotesRef,
modifiedCount: vault.modifiedFiles.length,
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
selection,
@@ -587,7 +589,7 @@ function App() {
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} 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} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} views={vault.views} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} 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} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} views={vault.views} visibleNotesRef={visibleNotesRef} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />

View File

@@ -174,4 +174,17 @@ describe('BreadcrumbBar — raw editor toggle', () => {
fireEvent.click(screen.getByTitle('Raw editor'))
expect(onToggleRaw).toHaveBeenCalledOnce()
})
it('hides raw toggle when forceRawMode is true (non-markdown file)', () => {
const onToggleRaw = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={true} onToggleRaw={onToggleRaw} forceRawMode={true} />)
expect(screen.queryByTitle('Raw editor')).not.toBeInTheDocument()
expect(screen.queryByTitle('Back to editor')).not.toBeInTheDocument()
})
it('shows raw toggle when forceRawMode is false (markdown file)', () => {
const onToggleRaw = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} forceRawMode={false} />)
expect(screen.getByTitle('Raw editor')).toBeInTheDocument()
})
})

View File

@@ -26,6 +26,8 @@ interface BreadcrumbBarProps {
onToggleDiff: () => void
rawMode?: boolean
onToggleRaw?: () => void
/** When true, raw mode is forced (non-markdown file) — hide the toggle. */
forceRawMode?: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
inspectorCollapsed?: boolean
@@ -58,7 +60,7 @@ function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggle
}
function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff,
rawMode, onToggleRaw,
rawMode, onToggleRaw, forceRawMode,
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
onToggleFavorite, onToggleOrganized, onTrash, onRestore, onArchive, onUnarchive,
}: Omit<BreadcrumbBarProps, 'wordCount'>) {
@@ -112,7 +114,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
<GitBranch size={16} />
</button>
)}
<RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
style={DISABLED_ICON_STYLE}

View File

@@ -71,14 +71,14 @@ const SUGGESTED_PROPERTIES = ['Status', 'Date', 'URL'] as const
function SuggestedPropertySlot({ label, onAdd }: { label: string; onAdd: () => void }) {
return (
<button
className="grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded border-none bg-transparent px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary cursor-pointer"
className="grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded border-none bg-transparent px-1.5 text-left outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary cursor-pointer"
tabIndex={0}
onClick={onAdd}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onAdd() } }}
data-testid="suggested-property"
>
<span className="min-w-0 truncate text-[12px] text-muted-foreground/50">{label}</span>
<span className="min-w-0 truncate text-right text-[12px] text-muted-foreground/30">{'\u2014'}</span>
<span className="min-w-0 truncate text-[12px] text-muted-foreground/30">{'\u2014'}</span>
</button>
)
}

View File

@@ -127,7 +127,7 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) {
function ActiveTabBreadcrumb({ activeTab, barRef, props }: {
activeTab: Tab
barRef: React.RefObject<HTMLDivElement | null>
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave' | 'onDeleteNote'>
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave' | 'onDeleteNote'> & { forceRawMode?: boolean }
}) {
const wordCount = countWords(activeTab.content)
const path = activeTab.entry.path
@@ -142,6 +142,7 @@ function ActiveTabBreadcrumb({ activeTab, barRef, props }: {
onToggleDiff={props.onToggleDiff}
rawMode={props.rawMode}
onToggleRaw={props.onToggleRaw}
forceRawMode={props.forceRawMode}
showAIChat={props.showAIChat}
onToggleAIChat={props.onToggleAIChat}
inspectorCollapsed={props.inspectorCollapsed}
@@ -222,7 +223,7 @@ export function EditorContent({
<ActiveTabBreadcrumb
activeTab={activeTab}
barRef={breadcrumbBarRef}
props={{ diffMode, diffContent, onToggleDiff, rawMode: effectiveRawMode, onToggleRaw, ...breadcrumbProps }}
props={{ diffMode, diffContent, onToggleDiff, rawMode: effectiveRawMode, onToggleRaw, forceRawMode: isNonMarkdownText, ...breadcrumbProps }}
/>
{isTrashed && (
<TrashedNoteBanner

View File

@@ -91,7 +91,7 @@ describe('FilterBuilder wikilink autocomplete', () => {
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
})
it('inserts [[note-title]] when a note is selected', () => {
it('inserts [[stem|title]] when a note is selected', () => {
renderWithEntries({
all: [{ field: 'title', op: 'contains', value: '[[Alpha' }],
})
@@ -100,7 +100,7 @@ describe('FilterBuilder wikilink autocomplete', () => {
fireEvent.click(screen.getByText('Alpha Project'))
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
all: [{ field: 'title', op: 'contains', value: '[[Alpha Project]]' }],
all: [{ field: 'title', op: 'contains', value: '[[alpha|Alpha Project]]' }],
}),
)
})

View File

@@ -112,8 +112,10 @@ function toWikilinkMatch(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>
const isA = e.isA
const te = typeEntryMap[isA ?? '']
const noteType = isA || undefined
const stem = e.filename.replace(/\.md$/, '')
return {
title: e.title,
stem,
noteType,
typeColor: noteType ? getTypeColor(isA, te?.color) : undefined,
typeLightColor: noteType ? getTypeLightColor(isA, te?.color) : undefined,
@@ -150,7 +152,7 @@ function useOutsideClick(refs: React.RefObject<HTMLElement | null>[], onClose: (
function WikilinkDropdown({ matches, selectedIndex, onSelect, onHover, menuRef, anchorRef }: {
matches: WikilinkMatch[]
selectedIndex: number
onSelect: (title: string) => void
onSelect: (title: string, stem?: string) => void
onHover: (index: number) => void
menuRef: React.RefObject<HTMLDivElement | null>
anchorRef: React.RefObject<HTMLElement | null>
@@ -178,7 +180,7 @@ function WikilinkDropdown({ matches, selectedIndex, onSelect, onHover, menuRef,
key={item.title}
className={`wikilink-menu__item${index === selectedIndex ? ' wikilink-menu__item--selected' : ''}`}
onMouseDown={e => e.preventDefault()}
onClick={() => onSelect(item.title)}
onClick={() => onSelect(item.title, item.stem)}
onMouseEnter={() => onHover(index)}
>
<span className="wikilink-menu__title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -217,7 +219,7 @@ function useScrollSelectedIntoView(menuRef: React.RefObject<HTMLDivElement | nul
function useDropdownKeyboard(
matches: WikilinkMatch[],
open: boolean,
onSelect: (title: string) => void,
onSelect: (title: string, stem?: string) => void,
onClose: () => void,
) {
const [selectedIndex, setSelectedIndex] = useState(-1)
@@ -234,7 +236,8 @@ function useDropdownKeyboard(
setSelectedIndex(i => (i <= 0 ? matches.length - 1 : i - 1))
} else if (e.key === 'Enter' && selectedIndex >= 0) {
e.preventDefault()
onSelect(matches[selectedIndex].title)
const m = matches[selectedIndex]
onSelect(m.title, m.stem)
} else if (e.key === 'Escape') {
e.preventDefault()
onClose()
@@ -255,8 +258,9 @@ function WikilinkValueInput({ value, entries, onChange }: {
const matches = useWikilinkMatches(entries, value, open)
const handleSelect = useCallback((title: string) => {
onChange(`[[${title}]]`)
const handleSelect = useCallback((title: string, stem?: string) => {
const wikilink = stem && stem !== title ? `[[${stem}|${title}]]` : `[[${title}]]`
onChange(wikilink)
setOpen(false)
}, [onChange])

View File

@@ -180,7 +180,7 @@ describe('DynamicRelationshipsPanel', () => {
fireEvent.click(screen.getByText('+ Add relationship'))
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Related to' } })
fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } })
fireEvent.click(screen.getByText('Add'))
fireEvent.click(screen.getByTestId('submit-add-relationship'))
expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[AI]]')
})

View File

@@ -140,9 +140,10 @@ interface NoteListProps {
onDiscardFile?: (relativePath: string) => Promise<void>
onAutoTriggerDiff?: () => void
views?: ViewFile[]
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
}
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, onAutoTriggerDiff, views }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, onAutoTriggerDiff, views, visibleNotesRef }: NoteListProps) {
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
const { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills } = useViewFlags(selection)
@@ -169,6 +170,12 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
return map
}, [isChangesView, modifiedFiles])
const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views })
// Keep the visible notes ref in sync for keyboard navigation (Cmd+Option+Arrow)
if (visibleNotesRef) {
visibleNotesRef.current = isEntityView
? searchedGroups.flatMap((g) => g.entries)
: searched
}
const deletedCount = useMemo(
() => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0,
[isChangesView, modifiedFiles],

View File

@@ -392,7 +392,7 @@ function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorde
{!collapsed && (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={favIds} strategy={verticalListSortingStrategy}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, paddingBottom: 4 }}>
{favorites.map((entry) => {
const isActive = isSelectionActive(selection, { kind: 'entity', entry })
return (

View File

@@ -217,7 +217,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
if (refs.length === 0) return null
return (
<div className="mb-2.5">
<span className="font-mono-overline mb-1 block text-muted-foreground">{label}</span>
<span className="mb-1 block text-[12px] text-muted-foreground">{label}</span>
<div className="flex flex-col gap-1">
{refs.map((ref, idx) => {
const props = resolveRefProps(ref, entries, typeEntryMap)
@@ -368,7 +368,7 @@ function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: {
onSubmitWithCreate={handleCreateAndSubmit}
/>
<div className="flex gap-1.5">
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={() => submitForm()} disabled={!relKey.trim() || !relTarget.trim()}>Add</button>
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={() => submitForm()} disabled={!relKey.trim() || !relTarget.trim()} data-testid="submit-add-relationship">Add</button>
<button className="border border-border bg-transparent text-xs text-muted-foreground" style={{ borderRadius: 4, padding: '4px 8px' }} onClick={resetForm}>Cancel</button>
</div>
</div>
@@ -402,32 +402,15 @@ function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote
onAdd: (noteTitle: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
const [active, setActive] = useState(false)
if (active) {
return (
<div className="mb-2.5">
<span className="font-mono-overline mb-1 block text-muted-foreground/50">{label}</span>
<InlineAddNote
entries={entries}
onAdd={(noteTitle) => { onAdd(noteTitle); setActive(false) }}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
</div>
)
}
return (
<button
className="mb-2.5 flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-0 py-1 text-left outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary"
tabIndex={0}
onClick={() => setActive(true)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setActive(true) } }}
data-testid="suggested-relationship"
>
<span className="font-mono-overline text-muted-foreground/50">{label}</span>
<span className="text-[12px] text-muted-foreground/30">{'\u2014'}</span>
</button>
<div className="mb-2.5" data-testid="suggested-relationship">
<span className="mb-1 block text-[12px] text-muted-foreground/50">{label}</span>
<InlineAddNote
entries={entries}
onAdd={onAdd}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
</div>
)
}

View File

@@ -12,6 +12,7 @@ interface AppCommandsConfig {
activeTabPath: string | null
activeTabPathRef: React.MutableRefObject<string | null>
entries: VaultEntry[]
visibleNotesRef: React.RefObject<VaultEntry[]>
modifiedCount: number
selection: SidebarSelection
onQuickOpen: () => void
@@ -219,8 +220,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
useKeyboardNavigation({
activeTabPath: config.activeTabPath,
entries: config.entries,
selection: config.selection,
visibleNotesRef: config.visibleNotesRef,
onReplaceActiveTab: config.onReplaceActiveTab,
onSelectNote: config.onSelectNote,
})

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import type { VaultEntry, SidebarSelection } from '../types'
import type { VaultEntry } from '../types'
import { useKeyboardNavigation } from './useKeyboardNavigation'
vi.mock('../mock-tauri', () => ({
@@ -44,13 +44,11 @@ describe('useKeyboardNavigation', () => {
makeEntry({ path: '/vault/c.md', title: 'C', modifiedAt: 1700000001 }),
]
const selection: SidebarSelection = { kind: 'filter', filter: 'all' }
let addedListeners: { type: string; handler: EventListenerOrEventListenerObject }[] = []
beforeEach(() => {
vi.clearAllMocks()
addedListeners = []
// Track added listeners for cleanup verification
const origAdd = window.addEventListener
const origRemove = window.removeEventListener
vi.spyOn(window, 'addEventListener').mockImplementation((type: string, handler: EventListenerOrEventListenerObject, opts?: boolean | AddEventListenerOptions) => {
@@ -66,24 +64,25 @@ describe('useKeyboardNavigation', () => {
vi.restoreAllMocks()
})
it('registers keydown listener on mount', () => {
renderHook(() =>
function renderNav(activeTabPath: string | null, noteList: VaultEntry[] = entries) {
const visibleNotesRef = { current: noteList }
return renderHook(() =>
useKeyboardNavigation({
activeTabPath: '/vault/a.md', entries, selection,
onReplaceActiveTab, onSelectNote,
activeTabPath,
visibleNotesRef,
onReplaceActiveTab,
onSelectNote,
})
)
}
it('registers keydown listener on mount', () => {
renderNav('/vault/a.md')
expect(addedListeners.some(l => l.type === 'keydown')).toBe(true)
})
it('navigates to next note on Cmd+Alt+ArrowDown', () => {
renderHook(() =>
useKeyboardNavigation({
activeTabPath: '/vault/a.md', entries, selection,
onReplaceActiveTab, onSelectNote,
})
)
renderNav('/vault/a.md')
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
@@ -91,16 +90,11 @@ describe('useKeyboardNavigation', () => {
}))
})
expect(onReplaceActiveTab).toHaveBeenCalled()
expect(onReplaceActiveTab).toHaveBeenCalledWith(entries[1])
})
it('navigates to previous note on Cmd+Alt+ArrowUp', () => {
renderHook(() =>
useKeyboardNavigation({
activeTabPath: '/vault/b.md', entries, selection,
onReplaceActiveTab, onSelectNote,
})
)
renderNav('/vault/b.md')
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
@@ -108,16 +102,11 @@ describe('useKeyboardNavigation', () => {
}))
})
expect(onReplaceActiveTab).toHaveBeenCalled()
expect(onReplaceActiveTab).toHaveBeenCalledWith(entries[0])
})
it('selects first note when no active tab', () => {
renderHook(() =>
useKeyboardNavigation({
activeTabPath: null, entries, selection,
onReplaceActiveTab, onSelectNote,
})
)
it('does not wrap when at the last note and pressing Down', () => {
renderNav('/vault/c.md')
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
@@ -125,16 +114,49 @@ describe('useKeyboardNavigation', () => {
}))
})
expect(onSelectNote).toHaveBeenCalled()
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
})
it('does not wrap when at the first note and pressing Up', () => {
renderNav('/vault/a.md')
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowUp', metaKey: true, altKey: true, bubbles: true,
}))
})
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
})
it('selects first note when no active tab and pressing Down', () => {
renderNav(null)
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowDown', metaKey: true, altKey: true, bubbles: true,
}))
})
expect(onSelectNote).toHaveBeenCalledWith(entries[0])
})
it('selects last note when no active tab and pressing Up', () => {
renderNav(null)
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowUp', metaKey: true, altKey: true, bubbles: true,
}))
})
expect(onSelectNote).toHaveBeenCalledWith(entries[2])
})
it('does nothing without modifier keys', () => {
renderHook(() =>
useKeyboardNavigation({
activeTabPath: '/vault/a.md', entries, selection,
onReplaceActiveTab, onSelectNote,
})
)
renderNav('/vault/a.md')
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
@@ -145,4 +167,32 @@ describe('useKeyboardNavigation', () => {
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
})
it('navigates in the order provided by visibleNotesRef (not by modifiedAt)', () => {
// Provide notes in reverse-alpha order (C, B, A) regardless of modifiedAt
const customOrder = [entries[2], entries[1], entries[0]]
renderNav('/vault/c.md', customOrder)
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowDown', metaKey: true, altKey: true, bubbles: true,
}))
})
// Should navigate to B (next in custom order), not based on modifiedAt
expect(onReplaceActiveTab).toHaveBeenCalledWith(entries[1])
})
it('does nothing when note list is empty', () => {
renderNav('/vault/a.md', [])
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowDown', metaKey: true, altKey: true, bubbles: true,
}))
})
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
})
})

View File

@@ -1,26 +1,13 @@
import { useEffect, useMemo, useRef } from 'react'
import { filterEntries, sortByModified, buildRelationshipGroups } from '../utils/noteListHelpers'
import type { VaultEntry, SidebarSelection } from '../types'
import { useEffect, useRef } from 'react'
import type { VaultEntry } from '../types'
interface KeyboardNavigationOptions {
activeTabPath: string | null
entries: VaultEntry[]
selection: SidebarSelection
visibleNotesRef: React.RefObject<VaultEntry[]>
onReplaceActiveTab: (entry: VaultEntry) => void
onSelectNote: (entry: VaultEntry) => void
}
function computeVisibleNotes(
entries: VaultEntry[],
selection: SidebarSelection,
): VaultEntry[] {
if (selection.kind === 'entity') {
return buildRelationshipGroups(selection.entry, entries)
.flatMap((g) => g.entries)
}
return [...filterEntries(entries, selection)].sort(sortByModified)
}
function navigateNote(
visibleNotesRef: React.RefObject<VaultEntry[]>,
activeTabPathRef: React.RefObject<string | null>,
@@ -36,7 +23,10 @@ function navigateNote(
const nextIndex = currentIndex === -1
? (direction === 1 ? 0 : notes.length - 1)
: (currentIndex + direction + notes.length) % notes.length
: currentIndex + direction
// Clamp to list bounds — don't wrap around
if (nextIndex < 0 || nextIndex >= notes.length) return
const nextNote = notes[nextIndex]
if (currentPath) {
@@ -53,16 +43,10 @@ function useLatestRef<T>(value: T): React.RefObject<T> {
}
export function useKeyboardNavigation({
activeTabPath, entries, selection,
activeTabPath, visibleNotesRef,
onReplaceActiveTab, onSelectNote,
}: KeyboardNavigationOptions) {
const visibleNotes = useMemo(
() => computeVisibleNotes(entries, selection),
[entries, selection],
)
const activeTabPathRef = useLatestRef(activeTabPath)
const visibleNotesRef = useLatestRef(visibleNotes)
const onReplaceRef = useLatestRef(onReplaceActiveTab)
const onSelectNoteRef = useLatestRef(onSelectNote)

View File

@@ -289,6 +289,58 @@ describe('evaluateView', () => {
expect(result.map((e) => e.title)).toEqual(['Yes'])
})
it('wikilink filter matches frontmatter with alias via path', () => {
const view: ViewDefinition = {
name: 'By path', icon: null, color: null, sort: null,
filters: { all: [{ field: 'belongs to', op: 'contains', value: '[[monday-112]]' }] },
}
const entries = [
makeEntry({ title: 'Match', relationships: { 'belongs to': ['[[monday-112|Monday #112]]'] } }),
makeEntry({ title: 'No match', relationships: { 'belongs to': ['[[tuesday-200|Tuesday]]'] } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Match'])
})
it('wikilink filter matches frontmatter with alias via alias', () => {
const view: ViewDefinition = {
name: 'By alias', icon: null, color: null, sort: null,
filters: { all: [{ field: 'belongs to', op: 'contains', value: '[[Monday #112]]' }] },
}
const entries = [
makeEntry({ title: 'Match', relationships: { 'belongs to': ['[[monday-112|Monday #112]]'] } }),
makeEntry({ title: 'No match', relationships: { 'belongs to': ['[[tuesday-200|Tuesday]]'] } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Match'])
})
it('wikilink filter with stem|title format matches frontmatter path', () => {
const view: ViewDefinition = {
name: 'Stem format', icon: null, color: null, sort: null,
filters: { all: [{ field: 'belongs to', op: 'contains', value: '[[monday-112|Monday 112]]' }] },
}
const entries = [
makeEntry({ title: 'Match', relationships: { 'belongs to': ['[[monday-112|Monday #112]]'] } }),
makeEntry({ title: 'No match', relationships: { 'belongs to': ['[[other]]'] } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Match'])
})
it('any_of on relationship uses alias matching', () => {
const view: ViewDefinition = {
name: 'Any of', icon: null, color: null, sort: null,
filters: { all: [{ field: 'belongs to', op: 'any_of', value: ['[[monday-112|Monday 112]]'] }] },
}
const entries = [
makeEntry({ title: 'Match', relationships: { 'belongs to': ['[[monday-112|Monday #112]]'] } }),
makeEntry({ title: 'No', relationships: { 'belongs to': ['[[other]]'] } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Match'])
})
it('body is_empty matches notes with empty snippet', () => {
const view: ViewDefinition = {
name: 'Empty body', icon: null, color: null, sort: null,

View File

@@ -51,6 +51,23 @@ function wikilinkStem(raw: string): string {
return s.toLowerCase()
}
/** Extract all comparable parts (path and alias) from a wikilink string. */
function wikilinkParts(raw: string): string[] {
let s = raw.trim()
if (s.startsWith('[[')) s = s.slice(2)
if (s.endsWith(']]')) s = s.slice(0, -2)
const pipe = s.indexOf('|')
if (pipe >= 0) return [s.substring(0, pipe).toLowerCase(), s.substring(pipe + 1).toLowerCase()]
return [s.toLowerCase()]
}
/** Check if two wikilink values match by comparing all path/alias combinations. */
function wikilinkEquals(a: string, b: string): boolean {
const partsA = wikilinkParts(a)
const partsB = wikilinkParts(b)
return partsA.some(pa => partsB.some(pb => pa === pb))
}
function toString(v: unknown): string {
if (v == null) return ''
if (typeof v === 'string') return v
@@ -78,17 +95,19 @@ function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean {
const stem = wikilinkStem(condVal)
const isWikilink = condVal.trim().startsWith('[[')
const arrayMatch = (arr: string[]) => arr.some((item) =>
isWikilink ? wikilinkStem(item) === stem : wikilinkStem(item).includes(stem)
isWikilink ? wikilinkEquals(item, condVal) : wikilinkStem(item).includes(stem)
)
if (op === 'contains') return arrayMatch(resolved.array)
if (op === 'not_contains') return !arrayMatch(resolved.array)
if (op === 'any_of' && Array.isArray(value)) {
const stems = (value as string[]).map(wikilinkStem)
return resolved.array.some((item) => stems.includes(wikilinkStem(item)))
return resolved.array.some((item) =>
(value as string[]).some((v) => wikilinkEquals(item, v))
)
}
if (op === 'none_of' && Array.isArray(value)) {
const stems = (value as string[]).map(wikilinkStem)
return !resolved.array.some((item) => stems.includes(wikilinkStem(item)))
return !resolved.array.some((item) =>
(value as string[]).some((v) => wikilinkEquals(item, v))
)
}
return false
}