fix: remove daily note command surfaces

This commit is contained in:
lucaronin
2026-04-12 23:14:03 +02:00
parent 73c1ecf8a9
commit 14dc07084d
20 changed files with 282 additions and 392 deletions

View File

@@ -697,7 +697,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `App.tsx` | `selection`, panel widths, dialog visibility, toast, view mode | UI state |
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
| `useNoteActions` | `tabs`, `activeTabPath` | Composes `useNoteCreation` + `useNoteRename` + `frontmatterOps` |
| `useNoteCreation` | — | Note/type/daily-note creation with optimistic persistence |
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
| `useNoteRename` | — | Note renaming with wikilink update |
| `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch |
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |

View File

@@ -82,7 +82,7 @@ tolaria/
│ │ ├── useVaultSwitcher.ts # Multi-vault management
│ │ ├── useVaultConfig.ts # Per-vault UI settings
│ │ ├── useNoteActions.ts # Composes creation + rename + frontmatter
│ │ ├── useNoteCreation.ts # Note/type/daily-note creation
│ │ ├── useNoteCreation.ts # Note/type creation
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
│ │ ├── useAiAgent.ts # AI agent state + tool tracking
│ │ ├── useAiActivity.ts # MCP UI bridge listener

View File

@@ -9,7 +9,6 @@ const APP_CHECK_FOR_UPDATES: &str = "app-check-for-updates";
const FILE_NEW_NOTE: &str = "file-new-note";
const FILE_NEW_TYPE: &str = "file-new-type";
const FILE_DAILY_NOTE: &str = "file-daily-note";
const FILE_QUICK_OPEN: &str = "file-quick-open";
const FILE_SAVE: &str = "file-save";
@@ -57,7 +56,6 @@ const CUSTOM_IDS: &[&str] = &[
APP_CHECK_FOR_UPDATES,
FILE_NEW_NOTE,
FILE_NEW_TYPE,
FILE_DAILY_NOTE,
FILE_QUICK_OPEN,
FILE_SAVE,
EDIT_FIND_IN_VAULT,
@@ -153,10 +151,6 @@ fn build_file_menu(app: &App) -> MenuResult {
let new_type = MenuItemBuilder::new("New Type")
.id(FILE_NEW_TYPE)
.build(app)?;
let daily_note = MenuItemBuilder::new("Open Today's Note")
.id(FILE_DAILY_NOTE)
.accelerator("CmdOrCtrl+J")
.build(app)?;
let quick_open = MenuItemBuilder::new("Quick Open")
.id(FILE_QUICK_OPEN)
.accelerator("CmdOrCtrl+P")
@@ -168,7 +162,6 @@ fn build_file_menu(app: &App) -> MenuResult {
Ok(SubmenuBuilder::new(app, "File")
.item(&new_note)
.item(&new_type)
.item(&daily_note)
.item(&quick_open)
.separator()
.item(&save)

View File

@@ -562,7 +562,6 @@ function App() {
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
onSearch: dialogs.openSearch,
onCreateNote: notes.handleCreateNoteImmediate,
onOpenDailyNote: notes.handleOpenDailyNote,
onCreateNoteOfType: notes.handleCreateNoteImmediate,
onSave: appSave.handleSave,
onOpenSettings: dialogs.openSettings,

View File

@@ -6,7 +6,6 @@ export const APP_COMMAND_IDS = {
appCheckForUpdates: 'app-check-for-updates',
fileNewNote: 'file-new-note',
fileNewType: 'file-new-type',
fileDailyNote: 'file-daily-note',
fileQuickOpen: 'file-quick-open',
fileSave: 'file-save',
editFindInVault: 'edit-find-in-vault',
@@ -77,7 +76,6 @@ type SimpleHandlerKey =
| 'onCheckForUpdates'
| 'onCreateNote'
| 'onCreateType'
| 'onOpenDailyNote'
| 'onQuickOpen'
| 'onSave'
| 'onSearch'
@@ -150,11 +148,6 @@ export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition>
route: { kind: 'handler', handler: 'onCreateType' },
menuOwned: true,
},
[APP_COMMAND_IDS.fileDailyNote]: {
route: { kind: 'handler', handler: 'onOpenDailyNote' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: 'j', code: 'KeyJ', display: '⌘J' },
},
[APP_COMMAND_IDS.fileQuickOpen]: {
route: { kind: 'handler', handler: 'onQuickOpen' },
menuOwned: true,
@@ -336,7 +329,6 @@ const NATIVE_MENU_COMMAND_SET = new Set<string>(
const MANUAL_NATIVE_ACCELERATOR_QA_COMMAND_SET = new Set<AppCommandId>([
APP_COMMAND_IDS.appSettings,
APP_COMMAND_IDS.fileNewNote,
APP_COMMAND_IDS.fileDailyNote,
APP_COMMAND_IDS.fileQuickOpen,
APP_COMMAND_IDS.fileSave,
APP_COMMAND_IDS.editFindInVault,

View File

@@ -20,7 +20,6 @@ function makeHandlers(): AppCommandHandlers {
onSetViewMode: vi.fn(),
onCreateNote: vi.fn(),
onCreateType: vi.fn(),
onOpenDailyNote: vi.fn(),
onQuickOpen: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
@@ -67,6 +66,10 @@ describe('appCommandDispatcher', () => {
expect(isAppCommandId('not-a-command')).toBe(false)
})
it('no longer recognizes the removed daily-note menu id', () => {
expect(isAppCommandId('file-daily-note')).toBe(false)
})
it('distinguishes native menu ids from keyboard-only ids', () => {
expect(isNativeMenuCommandId(APP_COMMAND_IDS.fileNewNote)).toBe(true)
expect(isNativeMenuCommandId(APP_COMMAND_IDS.noteToggleFavorite)).toBe(false)

View File

@@ -28,7 +28,6 @@ export interface AppCommandHandlers {
onSetViewMode: (mode: ViewMode) => void
onCreateNote: () => void
onCreateType?: () => void
onOpenDailyNote: () => void
onQuickOpen: () => void
onSave: () => void
onOpenSettings: () => void
@@ -70,7 +69,6 @@ type SimpleHandlerKey = keyof Pick<
| 'onCheckForUpdates'
| 'onCreateNote'
| 'onCreateType'
| 'onOpenDailyNote'
| 'onQuickOpen'
| 'onSave'
| 'onSearch'
@@ -108,7 +106,6 @@ const SIMPLE_HANDLER_EXECUTORS: Record<SimpleHandlerKey, (handlers: AppCommandHa
onCheckForUpdates: (handlers) => handlers.onCheckForUpdates?.(),
onCreateNote: (handlers) => handlers.onCreateNote(),
onCreateType: (handlers) => handlers.onCreateType?.(),
onOpenDailyNote: (handlers) => handlers.onOpenDailyNote(),
onQuickOpen: (handlers) => handlers.onQuickOpen(),
onSave: (handlers) => handlers.onSave(),
onSearch: (handlers) => handlers.onSearch(),

View File

@@ -12,7 +12,6 @@ export type KeyboardActions = Pick<
| 'onCommandPalette'
| 'onSearch'
| 'onCreateNote'
| 'onOpenDailyNote'
| 'onSave'
| 'onOpenSettings'
| 'onDeleteNote'

View File

@@ -5,7 +5,6 @@ interface NavigationCommandsConfig {
onQuickOpen: () => void
onSelect: (sel: SidebarSelection) => void
showInbox?: boolean
onOpenDailyNote: () => void
onGoBack?: () => void
onGoForward?: () => void
canGoBack?: boolean

View File

@@ -7,7 +7,6 @@ interface NoteCommandsConfig {
activeNoteHasIcon?: boolean
onCreateNote: () => void
onCreateType?: () => void
onOpenDailyNote: () => void
onSave: () => void
onDeleteNote: (path: string) => void
onArchiveNote: (path: string) => void
@@ -23,10 +22,34 @@ interface NoteCommandsConfig {
canRestoreDeletedNote?: boolean
}
interface ActivePathCommandConfig {
id: string
label: string
keywords: string[]
enabled: boolean
path: string | null
shortcut?: string
run: (path: string) => void
}
function createActivePathCommand(config: ActivePathCommandConfig): CommandAction {
return {
id: config.id,
label: config.label,
group: 'Note',
shortcut: config.shortcut,
keywords: config.keywords,
enabled: config.enabled,
execute: () => {
if (config.path) config.run(config.path)
},
}
}
export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
const {
hasActiveNote, activeTabPath, isArchived,
onCreateNote, onCreateType, onOpenDailyNote, onSave,
onCreateNote, onCreateType, onSave,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onOpenInNewWindow, onToggleFavorite, isFavorite,
@@ -37,36 +60,48 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
return [
{ id: 'create-note', label: 'New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'create', 'add'], enabled: true, execute: onCreateNote },
{ id: 'create-type', label: 'New Type', group: 'Note', keywords: ['new', 'create', 'type', 'template'], enabled: !!onCreateType, execute: () => onCreateType?.() },
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
{
id: 'delete-note', label: 'Delete Note', group: 'Note', shortcut: '⌘⌫',
keywords: ['delete', 'remove'], enabled: hasActiveNote,
execute: () => { if (activeTabPath) onDeleteNote(activeTabPath) },
},
{
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note',
keywords: ['archive'], enabled: hasActiveNote,
execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) },
},
createActivePathCommand({
id: 'delete-note',
label: 'Delete Note',
shortcut: '⌘⌫',
keywords: ['delete', 'remove'],
enabled: hasActiveNote,
path: activeTabPath,
run: onDeleteNote,
}),
createActivePathCommand({
id: 'archive-note',
label: isArchived ? 'Unarchive Note' : 'Archive Note',
keywords: ['archive'],
enabled: hasActiveNote,
path: activeTabPath,
run: isArchived ? onUnarchiveNote : onArchiveNote,
}),
{
id: 'restore-deleted-note', label: 'Restore Deleted Note', group: 'Note',
keywords: ['restore', 'deleted', 'undelete', 'git', 'checkout'],
enabled: !!canRestoreDeletedNote && !!onRestoreDeletedNote,
execute: () => onRestoreDeletedNote?.(),
},
{
id: 'toggle-favorite', label: isFavorite ? 'Remove from Favorites' : 'Add to Favorites', group: 'Note', shortcut: '⌘D',
createActivePathCommand({
id: 'toggle-favorite',
label: isFavorite ? 'Remove from Favorites' : 'Add to Favorites',
shortcut: '⌘D',
keywords: ['favorite', 'star', 'bookmark', 'pin'],
enabled: hasActiveNote && !!onToggleFavorite,
execute: () => { if (activeTabPath) onToggleFavorite?.(activeTabPath) },
},
{
id: 'toggle-organized', label: isOrganized ? 'Mark as Unorganized' : 'Mark as Organized', group: 'Note', shortcut: '⌘E',
path: activeTabPath,
run: (path) => onToggleFavorite?.(path),
}),
createActivePathCommand({
id: 'toggle-organized',
label: isOrganized ? 'Mark as Unorganized' : 'Mark as Organized',
shortcut: '⌘E',
keywords: ['organized', 'inbox', 'triage', 'done'],
enabled: hasActiveNote && !!onToggleOrganized,
execute: () => { if (activeTabPath) onToggleOrganized?.(activeTabPath) },
},
path: activeTabPath,
run: (path) => onToggleOrganized?.(path),
}),
{
id: 'set-note-icon', label: 'Set Note Icon', group: 'Note',
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],

View File

@@ -19,7 +19,6 @@ interface AppCommandsConfig {
onCommandPalette: () => void
onSearch: () => void
onCreateNote: () => void
onOpenDailyNote: () => void
onCreateNoteOfType: (type: string) => void
onSave: () => void
onOpenSettings: () => void
@@ -75,39 +74,17 @@ interface AppCommandsConfig {
canRestoreDeletedNote?: boolean
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
const entriesRef = useRef(config.entries)
// eslint-disable-next-line react-hooks/refs
entriesRef.current = config.entries
const toggleArchive = useCallback((path: string) => {
const entry = entriesRef.current.find(e => e.path === path)
;(entry?.archived ? config.onUnarchiveNote : config.onArchiveNote)(path)
}, [config.onArchiveNote, config.onUnarchiveNote])
const { onSelect } = config
const selectFilter = useCallback((filter: SidebarFilter) => {
const safeFilter = !config.showInbox && filter === 'inbox' ? 'all' : filter
onSelect({ kind: 'filter', filter: safeFilter })
}, [config.showInbox, onSelect])
const viewChanges = useCallback(() => {
onSelect({ kind: 'filter', filter: 'changes' })
}, [onSelect])
useAppKeyboard({
function createKeyboardActions(
config: AppCommandsConfig,
): Omit<Parameters<typeof useAppKeyboard>[0], 'onArchiveNote'> {
return {
onQuickOpen: config.onQuickOpen,
onCommandPalette: config.onCommandPalette,
onSearch: config.onSearch,
onCreateNote: config.onCreateNote,
onOpenDailyNote: config.onOpenDailyNote,
onSave: config.onSave,
onOpenSettings: config.onOpenSettings,
onDeleteNote: config.onDeleteNote,
onArchiveNote: toggleArchive,
onSetViewMode: config.onSetViewMode,
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
@@ -121,13 +98,18 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onToggleOrganized: config.onToggleOrganized,
onOpenInNewWindow: config.onOpenInNewWindow,
activeTabPathRef: config.activeTabPathRef,
})
}
}
useMenuEvents({
function createMenuEventHandlers(
config: AppCommandsConfig,
selectFilter: (filter: SidebarFilter) => void,
viewChanges: () => void,
): Omit<Parameters<typeof useMenuEvents>[0], 'onArchiveNote'> {
return {
onSetViewMode: config.onSetViewMode,
onCreateNote: config.onCreateNote,
onCreateType: config.onCreateType,
onOpenDailyNote: config.onOpenDailyNote,
onQuickOpen: config.onQuickOpen,
onSave: config.onSave,
onOpenSettings: config.onOpenSettings,
@@ -136,7 +118,6 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
onZoomReset: config.onZoomReset,
onArchiveNote: toggleArchive,
onDeleteNote: config.onDeleteNote,
onSearch: config.onSearch,
onToggleRawEditor: config.onToggleRawEditor,
@@ -163,9 +144,11 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
activeTabPath: config.activeTabPath,
modifiedCount: config.modifiedCount,
hasRestorableDeletedNote: config.canRestoreDeletedNote,
})
}
}
const commands = useCommandRegistry({
function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<typeof useCommandRegistry>[0] {
return {
activeTabPath: config.activeTabPath,
entries: config.entries,
modifiedCount: config.modifiedCount,
@@ -194,7 +177,6 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
zoomLevel: config.zoomLevel,
onSelect: config.onSelect,
showInbox: config.showInbox,
onOpenDailyNote: config.onOpenDailyNote,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
canGoBack: config.canGoBack,
@@ -222,7 +204,40 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
canCustomizeInboxColumns: config.canCustomizeInboxColumns,
onRestoreDeletedNote: config.onRestoreDeletedNote,
canRestoreDeletedNote: config.canRestoreDeletedNote,
})
}
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
const entriesRef = useRef(config.entries)
// eslint-disable-next-line react-hooks/refs
entriesRef.current = config.entries
const toggleArchive = useCallback((path: string) => {
const entry = entriesRef.current.find(e => e.path === path)
;(entry?.archived ? config.onUnarchiveNote : config.onArchiveNote)(path)
}, [config.onArchiveNote, config.onUnarchiveNote])
const { onSelect } = config
const selectFilter = useCallback((filter: SidebarFilter) => {
const safeFilter = !config.showInbox && filter === 'inbox' ? 'all' : filter
onSelect({ kind: 'filter', filter: safeFilter })
}, [config.showInbox, onSelect])
const viewChanges = useCallback(() => {
onSelect({ kind: 'filter', filter: 'changes' })
}, [onSelect])
const keyboardActions = createKeyboardActions(config)
const menuEventHandlers = createMenuEventHandlers(config, selectFilter, viewChanges)
useAppKeyboard({ ...keyboardActions, onArchiveNote: toggleArchive })
useMenuEvents({ ...menuEventHandlers, onArchiveNote: toggleArchive })
const commands = useCommandRegistry(createCommandRegistryConfig(config))
useKeyboardNavigation({
activeTabPath: config.activeTabPath,

View File

@@ -34,7 +34,6 @@ function makeActions() {
onCommandPalette: vi.fn(),
onSearch: vi.fn(),
onCreateNote: vi.fn(),
onOpenDailyNote: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onDeleteNote: vi.fn(),
@@ -169,11 +168,12 @@ describe('useAppKeyboard', () => {
})
})
it('Cmd+J triggers open daily note', () => {
it('Cmd+J no longer triggers any app command', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('j', { metaKey: true })
expect(actions.onOpenDailyNote).toHaveBeenCalled()
expect(actions.onCreateNote).not.toHaveBeenCalled()
expect(actions.onQuickOpen).not.toHaveBeenCalled()
})
it('Alt+4 does not trigger any view mode', () => {

View File

@@ -31,7 +31,6 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
onZoomReset: vi.fn(),
zoomLevel: 100,
onSelect: vi.fn(),
onOpenDailyNote: vi.fn(),
onCloseTab: vi.fn(),
onGoBack: vi.fn(),
onGoForward: vi.fn(),
@@ -207,6 +206,12 @@ describe('useCommandRegistry', () => {
expect(findCommand(result.current, 'go-inbox')).toBeUndefined()
})
it('omits the removed daily-note command', () => {
const config = makeConfig()
const { result } = renderHook(() => useCommandRegistry(config))
expect(findCommand(result.current, 'open-daily-note')).toBeUndefined()
})
it('includes Give Feedback in the Settings group when available', () => {
const onOpenFeedback = vi.fn()
const config = makeConfig({ onOpenFeedback })

View File

@@ -60,7 +60,6 @@ interface CommandRegistryConfig {
onZoomReset: () => void
zoomLevel: number
onSelect: (sel: SidebarSelection) => void
onOpenDailyNote: () => void
showInbox?: boolean
onGoBack?: () => void
onGoForward?: () => void
@@ -83,7 +82,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote,
onSelect,
showInbox,
onGoBack, onGoForward, canGoBack, canGoForward,
onCheckForUpdates, onCreateType,
@@ -110,10 +109,10 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
return useMemo(() => [
...buildNavigationCommands({ onQuickOpen, onSelect, onOpenDailyNote, showInbox, onGoBack, onGoForward, canGoBack, canGoForward }),
...buildNavigationCommands({ onQuickOpen, onSelect, showInbox, onGoBack, onGoForward, canGoBack, canGoForward }),
...buildNoteCommands({
hasActiveNote, activeTabPath, isArchived,
onCreateNote, onCreateType, onOpenDailyNote, onSave,
onCreateNote, onCreateType, onSave,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite,
onToggleOrganized, isOrganized: activeEntry?.organized ?? false,
@@ -139,7 +138,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCheckForUpdates,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote,
onSelect,
showInbox,
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes,

View File

@@ -23,7 +23,6 @@ function makeHandlers(): MenuEventHandlers {
onSetViewMode: vi.fn(),
onCreateNote: vi.fn(),
onCreateType: vi.fn(),
onOpenDailyNote: vi.fn(),
onQuickOpen: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
@@ -117,10 +116,11 @@ describe('dispatchMenuEvent', () => {
expect(h.onCreateNote).toHaveBeenCalled()
})
it('file-daily-note triggers open daily note', () => {
it('file-daily-note is ignored once the command is removed', () => {
const h = makeHandlers()
dispatchMenuEvent('file-daily-note', h)
expect(h.onOpenDailyNote).toHaveBeenCalled()
expect(h.onCreateNote).not.toHaveBeenCalled()
expect(h.onQuickOpen).not.toHaveBeenCalled()
})
it('file-quick-open triggers quick open', () => {

View File

@@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import { RAPID_CREATE_NOTE_SETTLE_MS, todayDateString } from './useNoteCreation'
import { RAPID_CREATE_NOTE_SETTLE_MS } from './useNoteCreation'
import { useNoteActions } from './useNoteActions'
import type { NoteActionsConfig } from './useNoteActions'
@@ -307,34 +307,6 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Property updated')
})
it('handleOpenDailyNote creates a new daily note when none exists', () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
result.current.handleOpenDailyNote()
})
expect(addEntry).toHaveBeenCalledTimes(1)
const [createdEntry] = addEntry.mock.calls[0]
expect(createdEntry.isA).toBe('Journal')
expect(createdEntry.path).toMatch(/\/\d{4}-\d{2}-\d{2}\.md$/)
})
it('handleOpenDailyNote opens existing daily note instead of creating', async () => {
const today = todayDateString()
const existing = makeEntry({ path: `/Users/luca/Laputa/${today}.md`, filename: `${today}.md`, title: today, isA: 'Journal' })
const { result } = renderHook(() => useNoteActions(makeConfig([existing])))
await act(async () => {
result.current.handleOpenDailyNote()
await new Promise((r) => setTimeout(r, 0))
})
// Should open existing note, not create a new one
expect(addEntry).not.toHaveBeenCalled()
expect(result.current.activeTabPath).toBe(`/Users/luca/Laputa/${today}.md`)
})
describe('pending save lifecycle', () => {
it('createAndPersist calls addPendingSave on start (non-Tauri)', async () => {
const addPendingSave = vi.fn()

View File

@@ -46,13 +46,25 @@ interface TitleRenameDeps {
updateTabContent: (path: string, content: string) => void
}
function applyFrontmatterCallbacks(config: NoteActionsConfig, path: string, newContent: string | undefined): boolean {
interface FrontmatterCallbackParams {
config: NoteActionsConfig
path: string
newContent: string | undefined
}
function applyFrontmatterCallbacks({ config, path, newContent }: FrontmatterCallbackParams): boolean {
if (!newContent) return false
config.onFrontmatterContentChanged?.(path, newContent)
return true
}
async function renameAfterTitleChange(path: string, newTitle: string, deps: TitleRenameDeps): Promise<void> {
interface RenameAfterTitleChangeParams {
path: string
newTitle: string
deps: TitleRenameDeps
}
async function renameAfterTitleChange({ path, newTitle, deps }: RenameAfterTitleChangeParams): Promise<void> {
const oldTitle = deps.tabsRef.current.find(t => t.entry.path === path)?.entry.title
const result = await performRename(path, newTitle, deps.vaultPath, oldTitle)
if (result.new_path !== path) {
@@ -73,21 +85,34 @@ function shouldRenameOnTitleUpdate(key: string, value: FrontmatterValue): value
return isTitleKey(key) && typeof value === 'string' && value !== ''
}
function navigateWikilink(entries: VaultEntry[], target: string, selectNote: (e: VaultEntry) => void): void {
interface NavigateWikilinkParams {
entries: VaultEntry[]
target: string
selectNote: (entry: VaultEntry) => void
}
function navigateWikilink({ entries, target, selectNote }: NavigateWikilinkParams): void {
const found = resolveEntry(entries, target)
if (found) selectNote(found)
else console.warn(`Navigation target not found: ${target}`)
}
async function maybeRenameAfterFrontmatterUpdate(
path: string,
key: string,
value: FrontmatterValue,
deps: TitleRenameDeps,
): Promise<void> {
interface MaybeRenameAfterFrontmatterUpdateParams {
path: string
key: string
value: FrontmatterValue
deps: TitleRenameDeps
}
async function maybeRenameAfterFrontmatterUpdate({
path,
key,
value,
deps,
}: MaybeRenameAfterFrontmatterUpdateParams): Promise<void> {
if (!shouldRenameOnTitleUpdate(key, value)) return
try {
await renameAfterTitleChange(path, value, deps)
await renameAfterTitleChange({ path, newTitle: value, deps })
} catch (err) {
console.error('Failed to rename note after title change:', err)
}
@@ -102,14 +127,14 @@ export function useNoteActions(config: NoteActionsConfig) {
setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t))
}, [setTabs])
const creation = useNoteCreation(config, { openTabWithContent, handleSelectNote })
const creation = useNoteCreation(config, { openTabWithContent })
const rename = useNoteRename(
{ entries, setToastMessage },
{ tabs: tabMgmt.tabs, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent },
)
const handleNavigateWikilink = useCallback(
(target: string) => navigateWikilink(entries, target, handleSelectNote),
(target: string) => navigateWikilink({ entries, target, selectNote: handleSelectNote }),
[entries, handleSelectNote],
)
@@ -125,26 +150,36 @@ export function useNoteActions(config: NoteActionsConfig) {
handleCreateNote: creation.handleCreateNote,
handleCreateNoteImmediate: creation.handleCreateNoteImmediate,
handleCreateNoteForRelationship: creation.handleCreateNoteForRelationship,
handleOpenDailyNote: creation.handleOpenDailyNote,
handleCreateType: creation.handleCreateType,
createTypeEntrySilent: creation.createTypeEntrySilent,
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => {
const newContent = await runFrontmatterOp('update', path, key, value, options)
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,
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
await maybeRenameAfterFrontmatterUpdate({
path,
key,
value,
deps: {
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 (!applyFrontmatterCallbacks(config, path, newContent)) return
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 (!applyFrontmatterCallbacks(config, path, newContent)) return
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config]),
handleRenameNote: rename.handleRenameNote,

View File

@@ -8,10 +8,6 @@ import {
buildNoteContent,
resolveNewNote,
resolveNewType,
todayDateString,
buildDailyNoteContent,
resolveDailyNote,
findDailyNote,
DEFAULT_TEMPLATES,
resolveTemplate,
} from './useNoteCreation'
@@ -319,76 +315,3 @@ describe('resolveNewType', () => {
expect(entry.path).not.toContain('/Users/luca/Laputa')
})
})
describe('todayDateString', () => {
it('returns date in YYYY-MM-DD format', () => {
const result = todayDateString()
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
})
describe('buildDailyNoteContent', () => {
it('generates frontmatter with Journal type and date', () => {
const content = buildDailyNoteContent('2026-03-02')
expect(content).toContain('type: Journal')
expect(content).toContain('date: 2026-03-02')
expect(content).toContain('title: 2026-03-02')
})
it('includes Intentions and Reflections sections', () => {
const content = buildDailyNoteContent('2026-03-02')
expect(content).toContain('## Intentions')
expect(content).toContain('## Reflections')
})
it('does not include H1 heading with the date', () => {
const content = buildDailyNoteContent('2026-03-02')
expect(content).not.toMatch(/^# /m)
})
})
describe('resolveDailyNote', () => {
it('creates entry at vault root with date as filename', () => {
const { entry } = resolveDailyNote('2026-03-02', '/my/vault')
expect(entry.path).toBe('/my/vault/2026-03-02.md')
expect(entry.filename).toBe('2026-03-02.md')
expect(entry.title).toBe('2026-03-02')
expect(entry.isA).toBe('Journal')
expect(entry.status).toBeNull()
})
it('returns content with daily note template', () => {
const { content } = resolveDailyNote('2026-03-02', '/my/vault')
expect(content).toContain('type: Journal')
expect(content).toContain('## Intentions')
})
it('uses provided vault path', () => {
const { entry } = resolveDailyNote('2026-03-02', '/other/vault')
expect(entry.path).toBe('/other/vault/2026-03-02.md')
})
})
describe('findDailyNote', () => {
it('finds entry by filename and Journal type', () => {
const entries = [
makeEntry({ path: '/Users/luca/Laputa/2026-03-02.md', filename: '2026-03-02.md', isA: 'Journal' }),
makeEntry({ path: '/Users/luca/Laputa/other.md' }),
]
const found = findDailyNote(entries, '2026-03-02')
expect(found).toBeDefined()
expect(found!.path).toBe('/Users/luca/Laputa/2026-03-02.md')
})
it('returns undefined when no matching entry exists', () => {
const entries = [makeEntry({ path: '/Users/luca/Laputa/other.md' })]
expect(findDailyNote(entries, '2026-03-02')).toBeUndefined()
})
it('does not match non-Journal notes with date filename', () => {
const entries = [
makeEntry({ path: '/vault/2026-03-02.md', filename: '2026-03-02.md', isA: 'Note' }),
]
expect(findDailyNote(entries, '2026-03-02')).toBeUndefined()
})
})

View File

@@ -13,10 +13,6 @@ import {
resolveNewType,
resolveTemplate,
DEFAULT_TEMPLATES,
todayDateString,
buildDailyNoteContent,
resolveDailyNote,
findDailyNote,
RAPID_CREATE_NOTE_SETTLE_MS,
useNoteCreation,
} from './useNoteCreation'
@@ -188,56 +184,16 @@ describe('resolveTemplate', () => {
})
})
describe('todayDateString', () => {
it('returns date in YYYY-MM-DD format', () => {
expect(todayDateString()).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
})
describe('buildDailyNoteContent', () => {
it('generates frontmatter with Journal type and date', () => {
const content = buildDailyNoteContent('2026-03-02')
expect(content).toContain('type: Journal')
expect(content).toContain('date: 2026-03-02')
expect(content).toContain('## Intentions')
})
})
describe('resolveDailyNote', () => {
it('creates entry with date as filename', () => {
const { entry } = resolveDailyNote('2026-03-02', '/vault')
expect(entry.path).toBe('/vault/2026-03-02.md')
expect(entry.isA).toBe('Journal')
})
})
describe('findDailyNote', () => {
it('finds entry by filename and Journal type', () => {
const entries = [makeEntry({ filename: '2026-03-02.md', isA: 'Journal' })]
expect(findDailyNote(entries, '2026-03-02')).toBeDefined()
})
it('returns undefined when no match', () => {
expect(findDailyNote([], '2026-03-02')).toBeUndefined()
})
it('does not match non-Journal notes', () => {
const entries = [makeEntry({ filename: '2026-03-02.md', isA: 'Note' })]
expect(findDailyNote(entries, '2026-03-02')).toBeUndefined()
})
})
describe('useNoteCreation hook', () => {
const addEntry = vi.fn()
const removeEntry = vi.fn()
const setToastMessage = vi.fn()
const openTabWithContent = vi.fn()
const handleSelectNote = vi.fn()
const makeConfig = (entries: VaultEntry[] = []): NoteCreationConfig => ({
addEntry, removeEntry, entries, setToastMessage, vaultPath: '/test/vault',
})
const tabDeps = { openTabWithContent, handleSelectNote }
const tabDeps = { openTabWithContent }
beforeEach(() => {
vi.clearAllMocks()
@@ -373,37 +329,6 @@ describe('useNoteCreation hook', () => {
window.removeEventListener('laputa:focus-editor', focusListener)
})
it('handleOpenDailyNote creates new daily note when none exists', () => {
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleOpenDailyNote() })
expect(addEntry).toHaveBeenCalledTimes(1)
expect(addEntry.mock.calls[0][0].isA).toBe('Journal')
})
it('handleOpenDailyNote opens existing daily note', () => {
const today = todayDateString()
const existing = makeEntry({ filename: `${today}.md`, isA: 'Journal' })
const { result } = renderHook(() => useNoteCreation(makeConfig([existing]), tabDeps))
act(() => { result.current.handleOpenDailyNote() })
expect(addEntry).not.toHaveBeenCalled()
expect(handleSelectNote).toHaveBeenCalledWith(existing)
})
it('handleOpenDailyNote requests focus for the daily note path', () => {
const focusListener = vi.fn()
window.addEventListener('laputa:focus-editor', focusListener)
const today = todayDateString()
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleOpenDailyNote() })
expect(focusListener).toHaveBeenCalledTimes(1)
const event = focusListener.mock.calls[0][0] as CustomEvent
expect(event.detail.path).toBe(`/test/vault/${today}.md`)
window.removeEventListener('laputa:focus-editor', focusListener)
})
it('handleCreateType creates type entry', () => {
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateType('Recipe') })

View File

@@ -134,24 +134,6 @@ export function resolveNewType({ typeName, vaultPath }: NewTypeParams): { entry:
return { entry, content: `---\ntype: Type\n---\n` }
}
export function todayDateString(): string {
return new Date().toISOString().split('T')[0]
}
export function buildDailyNoteContent(date: string): string {
const lines = ['---', `title: ${date}`, 'type: Journal', `date: ${date}`, '---']
return `${lines.join('\n')}\n\n## Intentions\n\n\n\n## Reflections\n\n`
}
export function resolveDailyNote(date: string, vaultPath: string): { entry: VaultEntry; content: string } {
const entry = buildNewEntry({ path: `${vaultPath}/${date}.md`, slug: date, title: date, type: 'Journal', status: null })
return { entry, content: buildDailyNoteContent(date) }
}
export function findDailyNote(entries: VaultEntry[], date: string): VaultEntry | undefined {
return entries.find(e => e.filename === `${date}.md` && e.isA === 'Journal')
}
/** Persist a newly created note to disk. Returns a Promise for error handling. */
export function persistNewNote(path: string, content: string): Promise<void> {
if (!isTauri()) return Promise.resolve()
@@ -190,11 +172,12 @@ function persistOptimistic(path: string, content: string, cbs: PersistCallbacks)
.catch(() => { cbs.onEnd?.(path); cbs.onFail(path) })
}
type PersistFn = (resolved: { entry: VaultEntry; content: string }) => void
type ResolvedNote = { entry: VaultEntry; content: string }
type PersistFn = (resolved: ResolvedNote) => void
/** Optimistically open note, add entry to vault, and persist to disk. */
function createAndPersist(
resolved: { entry: VaultEntry; content: string },
resolved: ResolvedNote,
addFn: (e: VaultEntry) => void,
openTab: (e: VaultEntry, c: string) => void,
cbs: PersistCallbacks,
@@ -204,16 +187,6 @@ function createAndPersist(
persistOptimistic(resolved.entry.path, resolved.content, cbs)
}
/** Open today's daily note: navigate to it if it exists, or create + persist a new one. */
function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => void, persist: PersistFn, vaultPath: string): void {
const date = todayDateString()
const existing = findDailyNote(entries, date)
const targetPath = existing?.path ?? `${vaultPath}/${date}.md`
if (existing) selectNote(existing)
else persist(resolveDailyNote(date, vaultPath))
signalFocusEditor({ path: targetPath })
}
interface ImmediateCreateDeps {
entries: VaultEntry[]
vaultPath: string
@@ -228,6 +201,15 @@ interface ImmediateCreateRequest {
type?: string
}
interface ImmediateCreateQueueConfig {
entries: VaultEntry[]
vaultPath: string
addEntry: (entry: VaultEntry) => void
openTabWithContent: (entry: VaultEntry, content: string) => void
trackUnsaved?: (path: string) => void
markContentPending?: (path: string, content: string) => void
}
/** Generate a unique untitled filename using a timestamp. */
function generateUntitledFilename(entries: VaultEntry[], type: string, pendingSlugs?: Set<string>): string {
const ts = Math.floor(Date.now() / 1000)
@@ -262,6 +244,82 @@ function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
signalFocusEditor({ path: entry.path, selectTitle: true })
}
function useImmediateCreateQueue(config: ImmediateCreateQueueConfig): (type?: string) => void {
const pendingSlugsRef = useRef<Set<string>>(new Set())
const queuedImmediateCreatesRef = useRef<ImmediateCreateRequest[]>([])
const immediateCreateLockedRef = useRef(false)
const immediateCreateTimerRef = useRef<number | null>(null)
const latestDepsRef = useRef<ImmediateCreateDeps | null>(null)
const syncDeps = useCallback(() => {
latestDepsRef.current = {
entries: config.entries,
vaultPath: config.vaultPath,
pendingSlugs: pendingSlugsRef.current,
openTabWithContent: config.openTabWithContent,
addEntry: config.addEntry,
trackUnsaved: config.trackUnsaved,
markContentPending: config.markContentPending,
}
}, [
config.entries,
config.vaultPath,
config.openTabWithContent,
config.addEntry,
config.trackUnsaved,
config.markContentPending,
])
useEffect(() => {
syncDeps()
}, [syncDeps])
const executeRequest = useCallback((request: ImmediateCreateRequest) => {
const deps = latestDepsRef.current
if (!deps) return
createNoteImmediate(deps, request.type)
trackEvent('note_created', {
has_type: request.type ? 1 : 0,
creation_path: request.type ? 'type_section' : 'cmd_n',
})
}, [])
const scheduleQueuedBurst = useCallback(function scheduleQueuedBurst() {
if (immediateCreateTimerRef.current !== null) return
immediateCreateTimerRef.current = window.setTimeout(() => {
immediateCreateTimerRef.current = null
const next = queuedImmediateCreatesRef.current.shift()
if (!next) {
immediateCreateLockedRef.current = false
return
}
executeRequest(next)
scheduleQueuedBurst()
}, RAPID_CREATE_NOTE_SETTLE_MS)
}, [executeRequest])
useEffect(() => () => {
if (immediateCreateTimerRef.current !== null) {
window.clearTimeout(immediateCreateTimerRef.current)
}
}, [])
return useCallback((type?: string) => {
syncDeps()
const request = { type }
if (immediateCreateLockedRef.current) {
queuedImmediateCreatesRef.current.push(request)
return
}
immediateCreateLockedRef.current = true
executeRequest(request)
scheduleQueuedBurst()
}, [syncDeps, executeRequest, scheduleQueuedBurst])
}
interface RelationshipCreateDeps {
entries: VaultEntry[]
vaultPath: string
@@ -303,40 +361,17 @@ export interface NoteCreationConfig {
interface CreationTabDeps {
openTabWithContent: (entry: VaultEntry, content: string) => void
handleSelectNote: (entry: VaultEntry) => void
}
export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTabDeps) {
const { addEntry, removeEntry, entries, setToastMessage, addPendingSave, removePendingSave } = config
const { openTabWithContent, handleSelectNote } = tabDeps
const { openTabWithContent } = tabDeps
const revertOptimisticNote = useCallback((path: string) => {
removeEntry(path)
setToastMessage('Failed to create note — disk write error')
}, [removeEntry, setToastMessage])
const pendingSlugsRef = useRef<Set<string>>(new Set())
const queuedImmediateCreatesRef = useRef<ImmediateCreateRequest[]>([])
const immediateCreateLockedRef = useRef(false)
const immediateCreateTimerRef = useRef<number | null>(null)
const latestImmediateCreateDepsRef = useRef<ImmediateCreateDeps | null>(null)
const syncImmediateCreateDeps = useCallback(() => {
latestImmediateCreateDepsRef.current = {
entries,
vaultPath: config.vaultPath,
pendingSlugs: pendingSlugsRef.current,
openTabWithContent,
addEntry,
trackUnsaved: config.trackUnsaved,
markContentPending: config.markContentPending,
}
}, [entries, config.vaultPath, openTabWithContent, addEntry, config.trackUnsaved, config.markContentPending])
useEffect(() => {
syncImmediateCreateDeps()
}, [syncImmediateCreateDeps])
const persistNew: PersistFn = useCallback(
(resolved) => createAndPersist(resolved, addEntry, openTabWithContent, {
onFail: revertOptimisticNote,
@@ -347,54 +382,21 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
[openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave, config.onNewNotePersisted],
)
const handleCreateNoteImmediate = useImmediateCreateQueue({
entries,
vaultPath: config.vaultPath,
addEntry,
openTabWithContent,
trackUnsaved: config.trackUnsaved,
markContentPending: config.markContentPending,
})
const handleCreateNote = useCallback((title: string, type: string) => {
const template = resolveTemplate({ entries, typeName: type })
persistNew(resolveNewNote({ title, type, vaultPath: config.vaultPath, template }))
trackEvent('note_created', { has_type: type !== 'Note' ? 1 : 0, creation_path: 'plus_button' })
}, [entries, persistNew, config.vaultPath])
const executeImmediateCreateRequest = useCallback((request: ImmediateCreateRequest) => {
const deps = latestImmediateCreateDepsRef.current
if (!deps) return
createNoteImmediate(deps, request.type)
trackEvent('note_created', { has_type: request.type ? 1 : 0, creation_path: request.type ? 'type_section' : 'cmd_n' })
}, [])
const continueImmediateCreateBurst = useCallback(function scheduleImmediateCreateBurst() {
if (immediateCreateTimerRef.current !== null) return
immediateCreateTimerRef.current = window.setTimeout(() => {
immediateCreateTimerRef.current = null
const next = queuedImmediateCreatesRef.current.shift()
if (!next) {
immediateCreateLockedRef.current = false
return
}
executeImmediateCreateRequest(next)
scheduleImmediateCreateBurst()
}, RAPID_CREATE_NOTE_SETTLE_MS)
}, [executeImmediateCreateRequest])
const handleCreateNoteImmediate = useCallback((type?: string) => {
syncImmediateCreateDeps()
const request = { type }
if (immediateCreateLockedRef.current) {
queuedImmediateCreatesRef.current.push(request)
return
}
immediateCreateLockedRef.current = true
executeImmediateCreateRequest(request)
continueImmediateCreateBurst()
}, [syncImmediateCreateDeps, executeImmediateCreateRequest, continueImmediateCreateBurst])
useEffect(() => () => {
if (immediateCreateTimerRef.current !== null) {
window.clearTimeout(immediateCreateTimerRef.current)
}
}, [])
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> => {
createNoteForRelationship({
entries, vaultPath: config.vaultPath, openTabWithContent, addEntry,
@@ -403,8 +405,6 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
return Promise.resolve(true)
}, [entries, openTabWithContent, addEntry, removeEntry, setToastMessage, config.vaultPath, config.onNewNotePersisted])
const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew, config.vaultPath), [entries, handleSelectNote, persistNew, config.vaultPath])
const handleCreateType = useCallback((typeName: string) => {
persistNew(resolveNewType({ typeName, vaultPath: config.vaultPath }))
trackEvent('type_created')
@@ -422,7 +422,6 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
handleCreateNote,
handleCreateNoteImmediate,
handleCreateNoteForRelationship,
handleOpenDailyNote,
handleCreateType,
createTypeEntrySilent,
}