Compare commits
9 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09f7498e86 | ||
|
|
7443b9a468 | ||
|
|
12f8fad0f0 | ||
|
|
c90b1d6694 | ||
|
|
806b552d47 | ||
|
|
2cd192fae6 | ||
|
|
46106da47a | ||
|
|
0051559b8e | ||
|
|
3f19528cb6 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.41
|
||||
AVERAGE_THRESHOLD=9.30
|
||||
HOTSPOT_THRESHOLD=9.33
|
||||
AVERAGE_THRESHOLD=9.27
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]]' }],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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]]')
|
||||
})
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user