Compare commits
9 Commits
v0.2026040
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42b1e18cc4 | ||
|
|
334d8bec66 | ||
|
|
b8983f16b2 | ||
|
|
2fabc2a1d7 | ||
|
|
2f32a1781a | ||
|
|
94ce91457d | ||
|
|
717d97f6f0 | ||
|
|
727a1b95d0 | ||
|
|
000d89df50 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.68
|
||||
AVERAGE_THRESHOLD=9.32
|
||||
AVERAGE_THRESHOLD=9.33
|
||||
|
||||
@@ -35,6 +35,7 @@ const GO_ARCHIVED: &str = "go-archived";
|
||||
const GO_CHANGES: &str = "go-changes";
|
||||
const GO_INBOX: &str = "go-inbox";
|
||||
|
||||
const NOTE_TOGGLE_ORGANIZED: &str = "note-toggle-organized";
|
||||
const NOTE_ARCHIVE: &str = "note-archive";
|
||||
const NOTE_DELETE: &str = "note-delete";
|
||||
const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window";
|
||||
@@ -77,6 +78,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
GO_ALL_NOTES,
|
||||
GO_ARCHIVED,
|
||||
GO_CHANGES,
|
||||
NOTE_TOGGLE_ORGANIZED,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_DELETE,
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
@@ -96,6 +98,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
/// IDs of menu items that should be disabled when no note tab is active.
|
||||
const NOTE_DEPENDENT_IDS: &[&str] = &[
|
||||
FILE_SAVE,
|
||||
NOTE_TOGGLE_ORGANIZED,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_DELETE,
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
@@ -273,9 +276,12 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
}
|
||||
|
||||
fn build_note_menu(app: &App) -> MenuResult {
|
||||
let toggle_organized = MenuItemBuilder::new("Toggle Organized")
|
||||
.id(NOTE_TOGGLE_ORGANIZED)
|
||||
.accelerator("CmdOrCtrl+E")
|
||||
.build(app)?;
|
||||
let archive_note = MenuItemBuilder::new("Archive Note")
|
||||
.id(NOTE_ARCHIVE)
|
||||
.accelerator("CmdOrCtrl+E")
|
||||
.build(app)?;
|
||||
let delete_note = MenuItemBuilder::new("Delete Note")
|
||||
.id(NOTE_DELETE)
|
||||
@@ -295,13 +301,14 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.build(app)?;
|
||||
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel")
|
||||
.id(VIEW_TOGGLE_AI_CHAT)
|
||||
.accelerator("CmdOrCtrl+Shift+L")
|
||||
.accelerator("Cmd+Shift+L")
|
||||
.build(app)?;
|
||||
let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks")
|
||||
.id(VIEW_TOGGLE_BACKLINKS)
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Note")
|
||||
.item(&toggle_organized)
|
||||
.item(&archive_note)
|
||||
.item(&delete_note)
|
||||
.item(&restore_deleted_note)
|
||||
|
||||
@@ -187,7 +187,7 @@ function App() {
|
||||
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
|
||||
})
|
||||
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry })
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry, onFrontmatterPersisted: vault.loadModifiedFiles })
|
||||
|
||||
// Note window: auto-open the note from URL params once vault entries load
|
||||
const noteWindowOpenedRef = useRef(false)
|
||||
@@ -381,7 +381,6 @@ function App() {
|
||||
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
|
||||
createTypeEntry: notes.createTypeEntrySilent,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
onBeforeAction: appSave.flushBeforeAction,
|
||||
})
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ describe('ArchivedNoteBanner', () => {
|
||||
expect(screen.getByText('Archived')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders unarchive button with keyboard hint', () => {
|
||||
it('renders unarchive button without the stale archive shortcut hint', () => {
|
||||
render(<ArchivedNoteBanner onUnarchive={vi.fn()} />)
|
||||
const btn = screen.getByTestId('unarchive-btn')
|
||||
expect(btn).toBeTruthy()
|
||||
expect(btn.textContent).toContain('Unarchive')
|
||||
expect(btn.title).toBe('Unarchive (Cmd+E)')
|
||||
expect(btn.title).toBe('Unarchive')
|
||||
})
|
||||
|
||||
it('calls onUnarchive when button is clicked', () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
|
||||
color: 'var(--muted-foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Unarchive (Cmd+E)"
|
||||
title="Unarchive"
|
||||
>
|
||||
<ArrowUUpLeft size={12} />
|
||||
Unarchive
|
||||
|
||||
@@ -74,31 +74,38 @@ describe('BreadcrumbBar — delete', () => {
|
||||
describe('BreadcrumbBar — archive/unarchive', () => {
|
||||
it('shows archive button for non-archived note', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
|
||||
expect(screen.getByTitle('Archive (Cmd+E)')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Unarchive (Cmd+E)')).not.toBeInTheDocument()
|
||||
expect(screen.getByTitle('Archive')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Unarchive')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows unarchive button for archived note', () => {
|
||||
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
|
||||
expect(screen.getByTitle('Unarchive (Cmd+E)')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Archive (Cmd+E)')).not.toBeInTheDocument()
|
||||
expect(screen.getByTitle('Unarchive')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Archive')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onArchive when archive button is clicked', () => {
|
||||
const onArchive = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={onArchive} />)
|
||||
fireEvent.click(screen.getByTitle('Archive (Cmd+E)'))
|
||||
fireEvent.click(screen.getByTitle('Archive'))
|
||||
expect(onArchive).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onUnarchive when unarchive button is clicked', () => {
|
||||
const onUnarchive = vi.fn()
|
||||
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onUnarchive={onUnarchive} />)
|
||||
fireEvent.click(screen.getByTitle('Unarchive (Cmd+E)'))
|
||||
fireEvent.click(screen.getByTitle('Unarchive'))
|
||||
expect(onUnarchive).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — organized shortcut hint', () => {
|
||||
it('shows Cmd+E on the organized toggle tooltip', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onToggleOrganized={vi.fn()} />)
|
||||
expect(screen.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', () => {
|
||||
it('always renders title elements in the DOM', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
|
||||
@@ -81,7 +81,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
entry.organized ? "text-green-600" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={onToggleOrganized}
|
||||
title={entry.organized ? 'Mark as unorganized (back to Inbox)' : 'Mark as organized (remove from Inbox)'}
|
||||
title={entry.organized ? 'Mark as unorganized (back to Inbox) (Cmd+E)' : 'Mark as organized (remove from Inbox) (Cmd+E)'}
|
||||
>
|
||||
<CheckCircle size={16} weight={entry.organized ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
@@ -137,7 +137,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={onUnarchive}
|
||||
title="Unarchive (Cmd+E)"
|
||||
title="Unarchive"
|
||||
>
|
||||
<ArrowUUpLeft size={16} />
|
||||
</button>
|
||||
@@ -145,7 +145,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={onArchive}
|
||||
title="Archive (Cmd+E)"
|
||||
title="Archive"
|
||||
>
|
||||
<Archive size={16} />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import type { FilterGroup } from '../types'
|
||||
|
||||
@@ -135,6 +135,23 @@ describe('FilterBuilder value inputs', () => {
|
||||
expect(screen.getByTestId('date-picker-trigger')).toHaveAttribute('title', 'Mar 28, 2026')
|
||||
})
|
||||
|
||||
it('keeps filter controls top-aligned when the date preview adds a second line', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-07T12:00:00Z'))
|
||||
|
||||
renderBuilder({
|
||||
all: [{ field: 'created', op: 'after', value: '10 days ago' }],
|
||||
})
|
||||
|
||||
fireEvent.focus(screen.getByTestId('date-value-input'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('date-value-preview')).toHaveTextContent('Resolves to March 28, 2026')
|
||||
expect(screen.getByTestId('filter-row')).toHaveClass('items-start')
|
||||
})
|
||||
|
||||
it('filters the field combobox as the user types', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
|
||||
@@ -141,7 +141,7 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
|
||||
const regexEnabled = regexSupported && condition.regex === true
|
||||
const invalidRegex = regexSupported && hasInvalidRegex(String(condition.value ?? ''), regexEnabled)
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-start gap-1.5" data-testid="filter-row">
|
||||
<FilterFieldCombobox
|
||||
value={condition.field}
|
||||
fields={fields}
|
||||
|
||||
@@ -54,6 +54,62 @@ function stepHighlightedIndex(current: number, optionCount: number, direction: '
|
||||
return (current - 1 + optionCount) % optionCount
|
||||
}
|
||||
|
||||
function moveHighlightedOption({
|
||||
event,
|
||||
open,
|
||||
options,
|
||||
direction,
|
||||
openCombobox,
|
||||
setHighlightedIndex,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLInputElement>
|
||||
open: boolean
|
||||
options: string[]
|
||||
direction: 'next' | 'previous'
|
||||
openCombobox: () => void
|
||||
setHighlightedIndex: (updater: number | ((current: number) => number)) => void
|
||||
}) {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, direction))
|
||||
}
|
||||
|
||||
function selectHighlightedOption({
|
||||
event,
|
||||
open,
|
||||
options,
|
||||
highlightedIndex,
|
||||
selectOption,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLInputElement>
|
||||
open: boolean
|
||||
options: string[]
|
||||
highlightedIndex: number
|
||||
selectOption: (value: string) => void
|
||||
}) {
|
||||
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
|
||||
event.preventDefault()
|
||||
selectOption(options[highlightedIndex])
|
||||
}
|
||||
|
||||
function closeOpenCombobox({
|
||||
event,
|
||||
open,
|
||||
closeCombobox,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLInputElement>
|
||||
open: boolean
|
||||
closeCombobox: () => void
|
||||
}) {
|
||||
if (!open) return
|
||||
event.preventDefault()
|
||||
closeCombobox()
|
||||
}
|
||||
|
||||
function handleFilterFieldKeyDown({
|
||||
event,
|
||||
open,
|
||||
@@ -75,32 +131,16 @@ function handleFilterFieldKeyDown({
|
||||
}) {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'next'))
|
||||
moveHighlightedOption({ event, open, options, direction: 'next', openCombobox, setHighlightedIndex })
|
||||
return
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'previous'))
|
||||
moveHighlightedOption({ event, open, options, direction: 'previous', openCombobox, setHighlightedIndex })
|
||||
return
|
||||
case 'Enter':
|
||||
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
|
||||
event.preventDefault()
|
||||
selectOption(options[highlightedIndex])
|
||||
selectHighlightedOption({ event, open, options, highlightedIndex, selectOption })
|
||||
return
|
||||
case 'Escape':
|
||||
if (!open) return
|
||||
event.preventDefault()
|
||||
closeCombobox()
|
||||
closeOpenCombobox({ event, open, closeCombobox })
|
||||
return
|
||||
default:
|
||||
return
|
||||
@@ -178,21 +218,27 @@ function FilterFieldPopoverPanel({
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="max-h-60 overflow-y-auto p-1"
|
||||
className="overflow-hidden p-1"
|
||||
style={{ width: contentWidth }}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
data-testid="filter-field-combobox-popover"
|
||||
>
|
||||
<div id={listboxId} role="listbox" data-testid="filter-field-combobox-options">
|
||||
<FilterFieldOptionsList
|
||||
listboxId={listboxId}
|
||||
fieldGroups={fieldGroups}
|
||||
options={options}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onHighlight={onHighlight}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
<div
|
||||
className="max-h-60 overflow-y-auto overscroll-contain"
|
||||
data-testid="filter-field-combobox-scroll-area"
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div id={listboxId} role="listbox" data-testid="filter-field-combobox-options">
|
||||
<FilterFieldOptionsList
|
||||
listboxId={listboxId}
|
||||
fieldGroups={fieldGroups}
|
||||
options={options}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onHighlight={onHighlight}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
@@ -910,6 +910,53 @@ describe('Sidebar', () => {
|
||||
fireEvent.click(screen.getByText('My Favorite Note'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'favorites' })
|
||||
})
|
||||
|
||||
it('matches the Types row styling and type color for favorites', () => {
|
||||
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
const favoriteLabel = screen.getByText('My Favorite Note')
|
||||
const favoriteRow = favoriteLabel.closest('.cursor-pointer')
|
||||
const typeLabel = screen.getByText('Projects')
|
||||
const typeRow = typeLabel.closest('.cursor-pointer')
|
||||
const favoriteIcon = favoriteRow?.querySelector('svg')
|
||||
|
||||
expect(favoriteRow?.className).toBe(typeRow?.className)
|
||||
expect(favoriteRow?.style.padding).toBe(typeRow?.style.padding)
|
||||
expect(favoriteRow?.style.gap).toBe(typeRow?.style.gap)
|
||||
expect(favoriteLabel.className).toContain(typeLabel.className)
|
||||
expect(favoriteLabel.className).toContain('truncate')
|
||||
expect(favoriteIcon?.getAttribute('width')).toBe('16')
|
||||
expect(favoriteIcon?.getAttribute('height')).toBe('16')
|
||||
expect(favoriteIcon?.getAttribute('style')).toContain('var(--accent-red)')
|
||||
})
|
||||
|
||||
it('falls back to a neutral icon color when the favorite type has no defined color', () => {
|
||||
const customType: VaultEntry = {
|
||||
path: '/vault/types/recipe.md', filename: 'recipe.md', title: 'Recipe',
|
||||
isA: 'Type', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 120, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: 'flask', color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
}
|
||||
const recipeFavorite: VaultEntry = {
|
||||
path: '/vault/recipe/sourdough.md', filename: 'sourdough.md', title: 'Sourdough',
|
||||
isA: 'Recipe', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 120, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
favorite: true, favoriteIndex: 0,
|
||||
}
|
||||
|
||||
render(<Sidebar entries={[...mockEntries, customType, recipeFavorite]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
const recipeRow = screen.getByText('Sourdough').closest('.cursor-pointer')
|
||||
const recipeIcon = recipeRow?.querySelector('svg')
|
||||
|
||||
expect(recipeIcon?.getAttribute('style')).toContain('var(--muted-foreground)')
|
||||
expect(within(recipeRow as HTMLElement).getByText('Sourdough')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('group separators', () => {
|
||||
@@ -1049,5 +1096,32 @@ describe('Sidebar', () => {
|
||||
const viewContainer = screen.getByText('Empty View').closest('div')
|
||||
expect(viewContainer?.querySelector('span:last-child')?.textContent).not.toBe('0')
|
||||
})
|
||||
|
||||
it('adds hover and focus classes that hide the view count chip while showing the action buttons', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
entries={mockEntries}
|
||||
selection={defaultSelection}
|
||||
onSelect={() => {}}
|
||||
views={mockViews}
|
||||
onEditView={() => {}}
|
||||
onDeleteView={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
const label = screen.getByText('Active Projects')
|
||||
const viewItem = label.closest('.group.relative') as HTMLElement
|
||||
const navItem = label.closest('[class*="cursor-pointer"]') as HTMLElement
|
||||
const countChip = navItem.querySelector('span:last-child') as HTMLElement
|
||||
expect(countChip).toBeTruthy()
|
||||
expect(viewItem.className).toContain('[&>div>span:last-child]:transition-opacity')
|
||||
expect(viewItem.className).toContain('group-hover:[&>div>span:last-child]:opacity-0')
|
||||
expect(viewItem.className).toContain('group-focus-within:[&>div>span:last-child]:opacity-0')
|
||||
|
||||
const actionButton = within(viewItem).getByTitle('Edit view')
|
||||
const actionContainer = actionButton.parentElement as HTMLElement
|
||||
expect(actionContainer.className).toContain('group-hover:opacity-100')
|
||||
expect(actionContainer.className).toContain('group-focus-within:opacity-100')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,4 +19,41 @@ describe('FilterFieldCombobox', () => {
|
||||
expect(listbox).toBeInTheDocument()
|
||||
expect(root.contains(listbox)).toBe(false)
|
||||
})
|
||||
|
||||
it('renders the option list inside a dedicated scroll container', () => {
|
||||
render(
|
||||
<FilterFieldCombobox
|
||||
value="status"
|
||||
fields={Array.from({ length: 20 }, (_, index) => `Field ${index + 1}`)}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.focus(screen.getByTestId('filter-field-combobox-input'))
|
||||
|
||||
expect(screen.getByTestId('filter-field-combobox-scroll-area')).toHaveClass(
|
||||
'max-h-60',
|
||||
'overflow-y-auto',
|
||||
'overscroll-contain',
|
||||
)
|
||||
})
|
||||
|
||||
it('stops wheel events from bubbling out of the scroll container', () => {
|
||||
const onWheel = vi.fn()
|
||||
|
||||
render(
|
||||
<div onWheel={onWheel}>
|
||||
<FilterFieldCombobox
|
||||
value="status"
|
||||
fields={Array.from({ length: 20 }, (_, index) => `Field ${index + 1}`)}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
|
||||
fireEvent.focus(screen.getByTestId('filter-field-combobox-input'))
|
||||
fireEvent.wheel(screen.getByTestId('filter-field-combobox-scroll-area'))
|
||||
|
||||
expect(onWheel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../SidebarParts'
|
||||
import { TypeCustomizePopover } from '../TypeCustomizePopover'
|
||||
import { useDragRegion } from '../../hooks/useDragRegion'
|
||||
import { buildTypeEntryMap, getTypeColor, getTypeLightColor } from '../../utils/typeColors'
|
||||
import { NoteTitleIcon } from '../NoteTitleIcon'
|
||||
|
||||
export interface SidebarSectionProps {
|
||||
@@ -86,7 +87,7 @@ function ViewItem({
|
||||
const count = useMemo(() => evaluateView(view.definition, entries).length, [view.definition, entries])
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<div className="group relative [&>div>span:last-child]:transition-opacity group-hover:[&>div>span:last-child]:opacity-0 group-focus-within:[&>div>span:last-child]:opacity-0">
|
||||
<NavItem
|
||||
icon={Funnel}
|
||||
emoji={view.definition.icon}
|
||||
@@ -95,7 +96,7 @@ function ViewItem({
|
||||
isActive={isActive}
|
||||
onClick={onSelect}
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{onEditView && (
|
||||
<button
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
@@ -334,16 +335,46 @@ export function TypesSection({
|
||||
)
|
||||
}
|
||||
|
||||
const FAVORITE_TYPE_ICON_MAP: Record<string, string> = {
|
||||
Project: 'wrench',
|
||||
project: 'wrench',
|
||||
Experiment: 'flask',
|
||||
experiment: 'flask',
|
||||
Responsibility: 'target',
|
||||
responsibility: 'target',
|
||||
Procedure: 'arrows-clockwise',
|
||||
procedure: 'arrows-clockwise',
|
||||
Person: 'users',
|
||||
person: 'users',
|
||||
Event: 'calendar-blank',
|
||||
event: 'calendar-blank',
|
||||
Topic: 'tag',
|
||||
topic: 'tag',
|
||||
Type: 'stack-simple',
|
||||
type: 'stack-simple',
|
||||
}
|
||||
|
||||
function getFavoriteIcon(entry: VaultEntry, typeEntryMap: Record<string, VaultEntry>) {
|
||||
const typeEntry = entry.isA ? typeEntryMap[entry.isA] : undefined
|
||||
return typeEntry?.icon ?? FAVORITE_TYPE_ICON_MAP[entry.isA ?? ''] ?? 'file-text'
|
||||
}
|
||||
|
||||
function SortableFavoriteItem({
|
||||
entry,
|
||||
isActive,
|
||||
onSelect,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
isActive: boolean
|
||||
onSelect: () => void
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.path })
|
||||
const typeEntry = entry.isA ? typeEntryMap[entry.isA] : undefined
|
||||
const icon = getFavoriteIcon(entry, typeEntryMap)
|
||||
const typeColor = getTypeColor(entry.isA ?? null, typeEntry?.color)
|
||||
const typeLightColor = getTypeLightColor(entry.isA ?? null, typeEntry?.color)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -353,17 +384,36 @@ function SortableFavoriteItem({
|
||||
{...listeners}
|
||||
>
|
||||
<div
|
||||
className={`flex cursor-pointer select-none items-center gap-1.5 rounded text-[13px] font-normal transition-colors ${isActive ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-accent'}`}
|
||||
style={{ padding: '4px 16px 4px 28px' }}
|
||||
className={`group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors ${isActive ? '' : 'hover:bg-accent'}`}
|
||||
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4, ...(isActive ? { background: typeLightColor } : {}) }}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<NoteTitleIcon icon={entry.icon} size={14} />
|
||||
<span className="truncate">{entry.title}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
|
||||
<NoteTitleIcon icon={icon} size={16} color={typeColor} />
|
||||
<span className="truncate text-[13px] font-medium" style={{ marginLeft: 4, color: isActive ? typeColor : undefined }}>
|
||||
{entry.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sortFavorites(entries: VaultEntry[]) {
|
||||
return entries
|
||||
.filter((entry) => entry.favorite && !entry.archived)
|
||||
.sort((a, b) => (a.favoriteIndex ?? Infinity) - (b.favoriteIndex ?? Infinity))
|
||||
}
|
||||
|
||||
function reorderFavoriteIds(favoriteIds: string[], event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return null
|
||||
const oldIndex = favoriteIds.indexOf(active.id as string)
|
||||
const newIndex = favoriteIds.indexOf(over.id as string)
|
||||
if (oldIndex === -1 || newIndex === -1) return null
|
||||
return arrayMove(favoriteIds, oldIndex, newIndex)
|
||||
}
|
||||
|
||||
export function FavoritesSection({
|
||||
entries,
|
||||
selection,
|
||||
@@ -381,22 +431,14 @@ export function FavoritesSection({
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const favorites = useMemo(
|
||||
() => entries
|
||||
.filter((entry) => entry.favorite && !entry.archived)
|
||||
.sort((a, b) => (a.favoriteIndex ?? Infinity) - (b.favoriteIndex ?? Infinity)),
|
||||
[entries],
|
||||
)
|
||||
const favorites = useMemo(() => sortFavorites(entries), [entries])
|
||||
const favoriteIds = useMemo(() => favorites.map((entry) => entry.path), [favorites])
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
const oldIndex = favoriteIds.indexOf(active.id as string)
|
||||
const newIndex = favoriteIds.indexOf(over.id as string)
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
onReorder?.(arrayMove(favoriteIds, oldIndex, newIndex))
|
||||
const reordered = reorderFavoriteIds(favoriteIds, event)
|
||||
if (reordered) onReorder?.(reordered)
|
||||
}, [favoriteIds, onReorder])
|
||||
|
||||
if (favorites.length === 0) return null
|
||||
@@ -413,6 +455,7 @@ export function FavoritesSection({
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
isActive={isSelectionActive(selection, { kind: 'entity', entry })}
|
||||
typeEntryMap={typeEntryMap}
|
||||
onSelect={() => {
|
||||
onSelect({ kind: 'filter', filter: 'favorites' })
|
||||
onSelectNote?.(entry)
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface KeyboardActions {
|
||||
onToggleRawEditor?: () => void
|
||||
onToggleInspector?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onOpenInNewWindow?: () => void
|
||||
activeTabPathRef: MutableRefObject<string | null>
|
||||
}
|
||||
@@ -38,8 +39,10 @@ const VIEW_MODE_KEYS: Record<string, ViewMode> = {
|
||||
}
|
||||
|
||||
function isTextInputFocused(): boolean {
|
||||
const tag = document.activeElement?.tagName
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA'
|
||||
const active = document.activeElement
|
||||
if (!(active instanceof HTMLElement)) return false
|
||||
if (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') return true
|
||||
return active.isContentEditable || active.closest('[contenteditable="true"]') !== null
|
||||
}
|
||||
|
||||
function isCommandOrCtrlOnly(e: KeyboardEvent): boolean {
|
||||
@@ -75,7 +78,7 @@ export function createCommandKeyMap(actions: KeyboardActions): ShortcutMap {
|
||||
s: actions.onSave,
|
||||
',': actions.onOpenSettings,
|
||||
d: withActiveTab(activeTabPathRef, (path) => actions.onToggleFavorite?.(path)),
|
||||
e: withActiveTab(activeTabPathRef, actions.onArchiveNote),
|
||||
e: withActiveTab(activeTabPathRef, (path) => actions.onToggleOrganized?.(path)),
|
||||
Backspace: withActiveTab(activeTabPathRef, actions.onDeleteNote),
|
||||
Delete: withActiveTab(activeTabPathRef, actions.onDeleteNote),
|
||||
'[': () => actions.onGoBack?.(),
|
||||
@@ -119,7 +122,8 @@ export function handleCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): boolean
|
||||
}
|
||||
|
||||
export function handleAiPanelKey(e: KeyboardEvent, onToggleAIChat?: () => void): boolean {
|
||||
if (isCommandShiftOnly(e) === false || e.key.toLowerCase() !== 'l' || onToggleAIChat === undefined) return false
|
||||
const matchesAiPanelShortcut = e.code === 'KeyL' || e.key.toLowerCase() === 'l'
|
||||
if (isCommandShiftOnly(e) === false || matchesAiPanelShortcut === false || onToggleAIChat === undefined) return false
|
||||
e.preventDefault()
|
||||
onToggleAIChat()
|
||||
return true
|
||||
|
||||
@@ -45,7 +45,7 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
execute: () => { if (activeTabPath) onDeleteNote(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note', shortcut: '⌘E',
|
||||
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note',
|
||||
keywords: ['archive'], enabled: hasActiveNote,
|
||||
execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) },
|
||||
},
|
||||
@@ -62,7 +62,7 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
execute: () => { if (activeTabPath) onToggleFavorite?.(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'toggle-organized', label: isOrganized ? 'Mark as Unorganized' : 'Mark as Organized', group: 'Note',
|
||||
id: 'toggle-organized', label: isOrganized ? 'Mark as Unorganized' : 'Mark as Organized', group: 'Note', shortcut: '⌘E',
|
||||
keywords: ['organized', 'inbox', 'triage', 'done'],
|
||||
enabled: hasActiveNote && !!onToggleOrganized,
|
||||
execute: () => { if (activeTabPath) onToggleOrganized?.(activeTabPath) },
|
||||
|
||||
@@ -115,6 +115,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onToggleInspector: config.onToggleInspector,
|
||||
onToggleFavorite: config.onToggleFavorite,
|
||||
onToggleOrganized: config.onToggleOrganized,
|
||||
onOpenInNewWindow: config.onOpenInNewWindow,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
})
|
||||
@@ -138,6 +139,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onToggleDiff: config.onToggleDiff,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onToggleOrganized: config.onToggleOrganized,
|
||||
onGoBack: config.onGoBack,
|
||||
onGoForward: config.onGoForward,
|
||||
onCheckForUpdates: config.onCheckForUpdates,
|
||||
|
||||
@@ -2,9 +2,21 @@ import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useAppKeyboard } from './useAppKeyboard'
|
||||
|
||||
function fireKey(key: string, mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {}) {
|
||||
function fireKey(
|
||||
key: string,
|
||||
mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; code?: string } = {},
|
||||
) {
|
||||
fireKeyOnTarget(window, key, mods)
|
||||
}
|
||||
|
||||
function fireKeyOnTarget(
|
||||
target: EventTarget,
|
||||
key: string,
|
||||
mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; code?: string } = {},
|
||||
) {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
code: mods.code,
|
||||
altKey: mods.altKey ?? false,
|
||||
metaKey: mods.metaKey ?? false,
|
||||
ctrlKey: mods.ctrlKey ?? false,
|
||||
@@ -12,7 +24,7 @@ function fireKey(key: string, mods: { altKey?: boolean; metaKey?: boolean; ctrlK
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
target.dispatchEvent(event)
|
||||
}
|
||||
|
||||
function makeActions() {
|
||||
@@ -26,6 +38,7 @@ function makeActions() {
|
||||
onOpenSettings: vi.fn(),
|
||||
onDeleteNote: vi.fn(),
|
||||
onArchiveNote: vi.fn(),
|
||||
onToggleOrganized: vi.fn(),
|
||||
onSetViewMode: vi.fn(),
|
||||
onZoomIn: vi.fn(),
|
||||
onZoomOut: vi.fn(),
|
||||
@@ -87,6 +100,14 @@ describe('useAppKeyboard', () => {
|
||||
expect(actions.onToggleFavorite).toHaveBeenCalledWith('/vault/test.md')
|
||||
})
|
||||
|
||||
it('Cmd+E triggers toggle organized on active note, not archive', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
fireKey('e', { metaKey: true })
|
||||
expect(actions.onToggleOrganized).toHaveBeenCalledWith('/vault/test.md')
|
||||
expect(actions.onArchiveNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+J triggers open daily note', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
@@ -130,6 +151,14 @@ describe('useAppKeyboard', () => {
|
||||
try { fn() } finally { document.body.removeChild(input) }
|
||||
}
|
||||
|
||||
function withFocusedContentEditable(fn: (editable: HTMLDivElement) => void) {
|
||||
const editable = document.createElement('div')
|
||||
editable.setAttribute('contenteditable', 'true')
|
||||
document.body.appendChild(editable)
|
||||
editable.focus()
|
||||
try { fn(editable) } finally { document.body.removeChild(editable) }
|
||||
}
|
||||
|
||||
it('Cmd+Backspace does not delete note when text input is focused', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
@@ -139,6 +168,15 @@ describe('useAppKeyboard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+Backspace does not delete note when contenteditable is focused', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
withFocusedContentEditable((editable) => {
|
||||
fireKeyOnTarget(editable, 'Backspace', { metaKey: true })
|
||||
expect(actions.onDeleteNote).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+Backspace deletes note when no text input is focused', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
@@ -201,6 +239,25 @@ describe('useAppKeyboard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+Shift+L works when editor stops propagation', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
|
||||
withFocusedContentEditable((editable) => {
|
||||
editable.addEventListener('keydown', (event) => event.stopPropagation())
|
||||
fireKeyOnTarget(editable, 'l', { metaKey: true, shiftKey: true })
|
||||
expect(onToggleAIChat).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+Shift+L matches by physical key code when the localized key differs', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
|
||||
fireKey('¬', { code: 'KeyL', metaKey: true, shiftKey: true })
|
||||
expect(onToggleAIChat).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Ctrl+Shift+L does not trigger toggle AI chat', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
|
||||
@@ -14,7 +14,7 @@ export function useAppKeyboard(actions: KeyboardActions) {
|
||||
onKeyDown(event)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
window.addEventListener('keydown', handleWindowKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleWindowKeyDown, true)
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||
onDeleteNote: vi.fn(),
|
||||
onArchiveNote: vi.fn(),
|
||||
onUnarchiveNote: vi.fn(),
|
||||
onToggleOrganized: vi.fn(),
|
||||
onCommitPush: vi.fn(),
|
||||
onResolveConflicts: vi.fn(),
|
||||
onSetViewMode: vi.fn(),
|
||||
@@ -192,6 +193,13 @@ describe('useCommandRegistry', () => {
|
||||
const cmd = findCommand(result.current, 'customize-inbox-columns')
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('shows Cmd+E on toggle organized and removes it from archive note', () => {
|
||||
const config = makeConfig()
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
expect(findCommand(result.current, 'toggle-organized')?.shortcut).toBe('⌘E')
|
||||
expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pluralizeType', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeHandlers(): MenuEventHandlers {
|
||||
onZoomIn: vi.fn(),
|
||||
onZoomOut: vi.fn(),
|
||||
onZoomReset: vi.fn(),
|
||||
onToggleOrganized: vi.fn(),
|
||||
onArchiveNote: vi.fn(),
|
||||
onDeleteNote: vi.fn(),
|
||||
onSearch: vi.fn(),
|
||||
@@ -143,6 +144,19 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onArchiveNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('note-toggle-organized triggers organized toggle on active tab', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('note-toggle-organized', h)
|
||||
expect(h.onToggleOrganized).toHaveBeenCalledWith('/vault/test.md')
|
||||
})
|
||||
|
||||
it('note-toggle-organized does nothing when no active tab', () => {
|
||||
const h = makeHandlers()
|
||||
h.activeTabPathRef = { current: null }
|
||||
dispatchMenuEvent('note-toggle-organized', h)
|
||||
expect(h.onToggleOrganized).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('note-delete triggers delete on active tab', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('note-delete', h)
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface MenuEventHandlers {
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onArchiveNote: (path: string) => void
|
||||
onDeleteNote: (path: string) => void
|
||||
onSearch: () => void
|
||||
@@ -106,7 +107,8 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
|
||||
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
|
||||
const path = h.activeTabPathRef.current
|
||||
if (!path) return id === 'note-archive' || id === 'note-delete'
|
||||
if (!path) return id === 'note-toggle-organized' || id === 'note-archive' || id === 'note-delete'
|
||||
if (id === 'note-toggle-organized') { h.onToggleOrganized?.(path); return true }
|
||||
if (id === 'note-archive') { h.onArchiveNote(path); return true }
|
||||
if (id === 'note-delete') { h.onDeleteNote(path); return true }
|
||||
return false
|
||||
|
||||
@@ -641,4 +641,3 @@ describe('useNoteActions hook', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
58
src/hooks/useNoteActions.persistence.test.ts
Normal file
58
src/hooks/useNoteActions.persistence.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useNoteActions, type NoteActionsConfig } from './useNoteActions'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: vi.fn(() => false),
|
||||
addMockEntry: vi.fn(),
|
||||
updateMockContent: vi.fn(),
|
||||
trackMockChange: vi.fn(),
|
||||
mockInvoke: vi.fn().mockResolvedValue(''),
|
||||
}))
|
||||
vi.mock('./mockFrontmatterHelpers', () => ({
|
||||
updateMockFrontmatter: vi.fn().mockReturnValue('---\nupdated: true\n---\n'),
|
||||
deleteMockFrontmatterProperty: vi.fn().mockReturnValue('---\n---\n'),
|
||||
}))
|
||||
|
||||
function makeConfig(onFrontmatterPersisted: () => void): NoteActionsConfig {
|
||||
return {
|
||||
addEntry: vi.fn(),
|
||||
removeEntry: vi.fn(),
|
||||
entries: [],
|
||||
setToastMessage: vi.fn(),
|
||||
updateEntry: vi.fn(),
|
||||
vaultPath: '/test/vault',
|
||||
onFrontmatterPersisted,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useNoteActions frontmatter persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'update',
|
||||
run: async (result: ReturnType<typeof renderHook<typeof useNoteActions>>['result']) => {
|
||||
await result.current.handleUpdateFrontmatter('/vault/note.md', '_list_properties_display', ['Owner', 'Status'])
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'delete',
|
||||
run: async (result: ReturnType<typeof renderHook<typeof useNoteActions>>['result']) => {
|
||||
await result.current.handleDeleteProperty('/vault/note.md', 'status')
|
||||
},
|
||||
},
|
||||
])('notifies after a frontmatter $label completes', async ({ run }) => {
|
||||
const onFrontmatterPersisted = vi.fn()
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig(onFrontmatterPersisted)))
|
||||
|
||||
await act(async () => {
|
||||
await run(result)
|
||||
})
|
||||
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,8 @@ export interface NoteActionsConfig {
|
||||
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { path: string }) => void
|
||||
/** Called after frontmatter is written to disk — used for live-reloading theme CSS vars. */
|
||||
onFrontmatterContentChanged?: (path: string, content: string) => void
|
||||
/** Called after a frontmatter mutation is fully persisted, including follow-up renames. */
|
||||
onFrontmatterPersisted?: () => void
|
||||
}
|
||||
|
||||
function isTitleKey(key: string): boolean {
|
||||
@@ -44,6 +46,12 @@ interface TitleRenameDeps {
|
||||
updateTabContent: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
function applyFrontmatterCallbacks(config: NoteActionsConfig, path: string, newContent: string | undefined): boolean {
|
||||
if (!newContent) return false
|
||||
config.onFrontmatterContentChanged?.(path, newContent)
|
||||
return true
|
||||
}
|
||||
|
||||
async function renameAfterTitleChange(path: string, newTitle: string, deps: TitleRenameDeps): Promise<void> {
|
||||
const oldTitle = deps.tabsRef.current.find(t => t.entry.path === path)?.entry.title
|
||||
const result = await performRename(path, newTitle, deps.vaultPath, oldTitle)
|
||||
@@ -71,6 +79,20 @@ function navigateWikilink(entries: VaultEntry[], target: string, selectNote: (e:
|
||||
else console.warn(`Navigation target not found: ${target}`)
|
||||
}
|
||||
|
||||
async function maybeRenameAfterFrontmatterUpdate(
|
||||
path: string,
|
||||
key: string,
|
||||
value: FrontmatterValue,
|
||||
deps: TitleRenameDeps,
|
||||
): Promise<void> {
|
||||
if (!shouldRenameOnTitleUpdate(key, value)) return
|
||||
try {
|
||||
await renameAfterTitleChange(path, value, deps)
|
||||
} catch (err) {
|
||||
console.error('Failed to rename note after title change:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export function useNoteActions(config: NoteActionsConfig) {
|
||||
const { entries, setToastMessage, updateEntry } = config
|
||||
const tabMgmt = useTabManagement()
|
||||
@@ -108,25 +130,22 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
createTypeEntrySilent: creation.createTypeEntrySilent,
|
||||
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => {
|
||||
const newContent = await runFrontmatterOp('update', path, key, value, options)
|
||||
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
|
||||
if (shouldRenameOnTitleUpdate(key, value)) {
|
||||
try {
|
||||
await renameAfterTitleChange(path, value, {
|
||||
vaultPath: config.vaultPath, tabsRef: rename.tabsRef, replaceEntry: config.replaceEntry,
|
||||
setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to rename note after title change:', err)
|
||||
}
|
||||
}
|
||||
if (!applyFrontmatterCallbacks(config, path, newContent)) return
|
||||
await maybeRenameAfterFrontmatterUpdate(path, key, value, {
|
||||
vaultPath: config.vaultPath, tabsRef: rename.tabsRef, replaceEntry: config.replaceEntry,
|
||||
setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent,
|
||||
})
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [runFrontmatterOp, config, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
|
||||
handleDeleteProperty: useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
|
||||
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
|
||||
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
|
||||
if (!applyFrontmatterCallbacks(config, path, newContent)) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [runFrontmatterOp, config]),
|
||||
handleAddProperty: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
|
||||
const newContent = await runFrontmatterOp('update', path, key, value)
|
||||
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
|
||||
if (!applyFrontmatterCallbacks(config, path, newContent)) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [runFrontmatterOp, config]),
|
||||
handleRenameNote: rename.handleRenameNote,
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ test.describe('AI panel shortcut', () => {
|
||||
await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('Cmd/Ctrl+Shift+L opens the AI panel', async ({ page }) => {
|
||||
test('Cmd+Shift+L opens the AI panel from the editor', async ({ page }) => {
|
||||
await page.locator('.app__note-list .cursor-pointer').first().click()
|
||||
await sendShortcut(page, 'L', ['Control', 'Shift'])
|
||||
await page.locator('.bn-editor').click()
|
||||
await sendShortcut(page, 'L', ['Meta', 'Shift'])
|
||||
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3_000 })
|
||||
await expect(page.getByTitle('Close AI panel')).toBeVisible()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user