Compare commits
4 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32b8e2ee57 | ||
|
|
b1110ead87 | ||
|
|
b75b917538 | ||
|
|
24bb64841e |
@@ -245,6 +245,7 @@ function App() {
|
||||
entries: vault.entries, updateEntry: vault.updateEntry,
|
||||
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
|
||||
createTypeEntry: notes.createTypeEntrySilent,
|
||||
})
|
||||
|
||||
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
|
||||
@@ -285,8 +286,8 @@ function App() {
|
||||
onCreateNoteOfType: notes.handleCreateNoteImmediate,
|
||||
onSave: handleSave,
|
||||
onOpenSettings: dialogs.openSettings,
|
||||
onTrashNote: entryActions.handleTrashNote, onArchiveNote: entryActions.handleArchiveNote,
|
||||
onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode,
|
||||
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
|
||||
@@ -151,8 +151,9 @@ function applyCustomization(
|
||||
): void {
|
||||
if (!target || !onCustomizeType) return
|
||||
const te = typeEntryMap[target]
|
||||
if (!te) return
|
||||
const [icon, color] = buildCustomizeArgs(te, prop, value)
|
||||
const [icon, color] = te
|
||||
? buildCustomizeArgs(te, prop, value)
|
||||
: [prop === 'icon' ? value : 'file-text', prop === 'color' ? value : 'blue']
|
||||
onCustomizeType(target, icon, color)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useAppKeyboard } from './useAppKeyboard'
|
||||
import { useCommandRegistry } from './useCommandRegistry'
|
||||
import type { CommandAction } from './useCommandRegistry'
|
||||
@@ -26,6 +27,7 @@ interface AppCommandsConfig {
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
onTrashNote: (path: string) => void
|
||||
onRestoreNote: (path: string) => void
|
||||
onArchiveNote: (path: string) => void
|
||||
onUnarchiveNote: (path: string) => void
|
||||
onCommitPush: () => void
|
||||
@@ -54,6 +56,20 @@ interface AppCommandsConfig {
|
||||
|
||||
/** 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 toggleTrash = useCallback((path: string) => {
|
||||
const entry = entriesRef.current.find(e => e.path === path)
|
||||
;(entry?.trashed ? config.onRestoreNote : config.onTrashNote)(path)
|
||||
}, [config.onTrashNote, config.onRestoreNote])
|
||||
|
||||
useAppKeyboard({
|
||||
onQuickOpen: config.onQuickOpen,
|
||||
onCommandPalette: config.onCommandPalette,
|
||||
@@ -62,8 +78,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onOpenDailyNote: config.onOpenDailyNote,
|
||||
onSave: config.onSave,
|
||||
onOpenSettings: config.onOpenSettings,
|
||||
onTrashNote: config.onTrashNote,
|
||||
onArchiveNote: config.onArchiveNote,
|
||||
onTrashNote: toggleTrash,
|
||||
onArchiveNote: toggleArchive,
|
||||
onSetViewMode: config.onSetViewMode,
|
||||
onZoomIn: config.onZoomIn,
|
||||
onZoomOut: config.onZoomOut,
|
||||
@@ -87,8 +103,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onZoomIn: config.onZoomIn,
|
||||
onZoomOut: config.onZoomOut,
|
||||
onZoomReset: config.onZoomReset,
|
||||
onArchiveNote: config.onArchiveNote,
|
||||
onTrashNote: config.onTrashNote,
|
||||
onArchiveNote: toggleArchive,
|
||||
onTrashNote: toggleTrash,
|
||||
onSearch: config.onSearch,
|
||||
onGoBack: config.onGoBack,
|
||||
onGoForward: config.onGoForward,
|
||||
@@ -107,6 +123,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onSave: config.onSave,
|
||||
onOpenSettings: config.onOpenSettings,
|
||||
onTrashNote: config.onTrashNote,
|
||||
onRestoreNote: config.onRestoreNote,
|
||||
onArchiveNote: config.onArchiveNote,
|
||||
onUnarchiveNote: config.onUnarchiveNote,
|
||||
onCommitPush: config.onCommitPush,
|
||||
|
||||
@@ -42,6 +42,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||
onSave: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onTrashNote: vi.fn(),
|
||||
onRestoreNote: vi.fn(),
|
||||
onArchiveNote: vi.fn(),
|
||||
onUnarchiveNote: vi.fn(),
|
||||
onCommitPush: vi.fn(),
|
||||
@@ -117,6 +118,44 @@ describe('useCommandRegistry', () => {
|
||||
expect(archiveCmd!.label).toBe('Archive Note')
|
||||
})
|
||||
|
||||
it('shows "Restore Note" when active note is trashed', () => {
|
||||
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: true })]
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })),
|
||||
)
|
||||
const trashCmd = result.current.find(c => c.id === 'trash-note')
|
||||
expect(trashCmd!.label).toBe('Restore Note')
|
||||
})
|
||||
|
||||
it('shows "Trash Note" when active note is not trashed', () => {
|
||||
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: false })]
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })),
|
||||
)
|
||||
const trashCmd = result.current.find(c => c.id === 'trash-note')
|
||||
expect(trashCmd!.label).toBe('Trash Note')
|
||||
})
|
||||
|
||||
it('calls onRestoreNote when trash command executes on trashed note', () => {
|
||||
const onRestoreNote = vi.fn()
|
||||
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: true })]
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries, onRestoreNote })),
|
||||
)
|
||||
result.current.find(c => c.id === 'trash-note')!.execute()
|
||||
expect(onRestoreNote).toHaveBeenCalledWith('/vault/note/test.md')
|
||||
})
|
||||
|
||||
it('calls onTrashNote when trash command executes on non-trashed note', () => {
|
||||
const onTrashNote = vi.fn()
|
||||
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: false })]
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries, onTrashNote })),
|
||||
)
|
||||
result.current.find(c => c.id === 'trash-note')!.execute()
|
||||
expect(onTrashNote).toHaveBeenCalledWith('/vault/note/test.md')
|
||||
})
|
||||
|
||||
it('disables commit when no modified files', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ modifiedCount: 0 })))
|
||||
expect(result.current.find(c => c.id === 'commit-push')!.enabled).toBe(false)
|
||||
|
||||
@@ -26,6 +26,7 @@ interface CommandRegistryConfig {
|
||||
onOpenSettings: () => void
|
||||
onOpenVault?: () => void
|
||||
onTrashNote: (path: string) => void
|
||||
onRestoreNote: (path: string) => void
|
||||
onArchiveNote: (path: string) => void
|
||||
onUnarchiveNote: (path: string) => void
|
||||
onCommitPush: () => void
|
||||
@@ -128,7 +129,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
const {
|
||||
activeTabPath, entries, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onArchiveNote, onUnarchiveNote,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
@@ -143,6 +144,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
[entries, activeTabPath, hasActiveNote],
|
||||
)
|
||||
const isArchived = activeEntry?.archived ?? false
|
||||
const isTrashed = activeEntry?.trashed ?? false
|
||||
|
||||
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
|
||||
|
||||
@@ -163,7 +165,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ 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: 'close-tab', label: 'Close Tab', group: 'Note', shortcut: '⌘W', keywords: [], enabled: hasActiveNote, execute: () => { if (activeTabPath) onCloseTab(activeTabPath) } },
|
||||
{ id: 'trash-note', label: 'Trash Note', group: 'Note', shortcut: '⌘⌫', keywords: ['delete', 'remove'], enabled: hasActiveNote, execute: () => { if (activeTabPath) onTrashNote(activeTabPath) } },
|
||||
{
|
||||
id: 'trash-note', label: isTrashed ? 'Restore Note' : 'Trash Note', group: 'Note', shortcut: '⌘⌫',
|
||||
keywords: ['delete', 'remove', 'restore', 'trash'], enabled: hasActiveNote,
|
||||
execute: () => { if (activeTabPath) (isTrashed ? onRestoreNote : onTrashNote)(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note', shortcut: '⌘E',
|
||||
keywords: ['archive'], enabled: hasActiveNote,
|
||||
@@ -197,9 +203,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
|
||||
return cmds
|
||||
}, [
|
||||
hasActiveNote, activeTabPath, isArchived, modifiedCount,
|
||||
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onArchiveNote, onUnarchiveNote,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
|
||||
@@ -36,6 +36,9 @@ describe('useEntryActions', () => {
|
||||
const handleUpdateFrontmatter = vi.fn().mockResolvedValue(undefined)
|
||||
const handleDeleteProperty = vi.fn().mockResolvedValue(undefined)
|
||||
const setToastMessage = vi.fn()
|
||||
const createTypeEntry = vi.fn().mockImplementation((typeName: string) =>
|
||||
Promise.resolve(makeEntry({ isA: 'Type', title: typeName, path: `/vault/type/${typeName.toLowerCase()}.md` })),
|
||||
)
|
||||
|
||||
function setup(entries: VaultEntry[] = []) {
|
||||
return renderHook(() =>
|
||||
@@ -45,6 +48,7 @@ describe('useEntryActions', () => {
|
||||
handleUpdateFrontmatter,
|
||||
handleDeleteProperty,
|
||||
setToastMessage,
|
||||
createTypeEntry,
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -118,12 +122,12 @@ describe('useEntryActions', () => {
|
||||
})
|
||||
|
||||
describe('handleCustomizeType', () => {
|
||||
it('updates icon and color on the type entry', () => {
|
||||
it('updates icon and color on the type entry', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
act(() => {
|
||||
result.current.handleCustomizeType('Recipe', 'cooking-pot', 'green')
|
||||
await act(async () => {
|
||||
await result.current.handleCustomizeType('Recipe', 'cooking-pot', 'green')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'cooking-pot')
|
||||
@@ -131,15 +135,33 @@ describe('useEntryActions', () => {
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'cooking-pot', color: 'green' })
|
||||
})
|
||||
|
||||
it('does nothing when type entry not found', () => {
|
||||
it('auto-creates type entry when not found and applies customization', async () => {
|
||||
const { result } = setup([])
|
||||
|
||||
act(() => {
|
||||
result.current.handleCustomizeType('NonExistent', 'star', 'red')
|
||||
await act(async () => {
|
||||
await result.current.handleCustomizeType('Recipe', 'star', 'red')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
expect(createTypeEntry).toHaveBeenCalledWith('Recipe')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'star', color: 'red' })
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'star')
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'color', 'red')
|
||||
})
|
||||
|
||||
it('serializes frontmatter writes (icon before color)', async () => {
|
||||
const callOrder: string[] = []
|
||||
handleUpdateFrontmatter.mockImplementation((_path: string, key: string) => {
|
||||
callOrder.push(key)
|
||||
return Promise.resolve()
|
||||
})
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCustomizeType('Project', 'wrench', 'blue')
|
||||
})
|
||||
|
||||
expect(callOrder).toEqual(['icon', 'color'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ interface EntryActionsConfig {
|
||||
handleUpdateFrontmatter: (path: string, key: string, value: string | number | boolean | string[]) => Promise<void>
|
||||
handleDeleteProperty: (path: string, key: string) => Promise<void>
|
||||
setToastMessage: (msg: string | null) => void
|
||||
createTypeEntry: (typeName: string) => Promise<VaultEntry>
|
||||
}
|
||||
|
||||
function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined {
|
||||
@@ -14,7 +15,7 @@ function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | un
|
||||
}
|
||||
|
||||
export function useEntryActions({
|
||||
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage,
|
||||
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry,
|
||||
}: EntryActionsConfig) {
|
||||
const handleTrashNote = useCallback(async (path: string) => {
|
||||
const now = new Date().toISOString().slice(0, 10)
|
||||
@@ -43,13 +44,13 @@ export function useEntryActions({
|
||||
setToastMessage('Note unarchived')
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
|
||||
|
||||
const handleCustomizeType = useCallback((typeName: string, icon: string, color: string) => {
|
||||
const typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) return
|
||||
handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
|
||||
handleUpdateFrontmatter(typeEntry.path, 'color', color)
|
||||
const handleCustomizeType = useCallback(async (typeName: string, icon: string, color: string) => {
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
|
||||
updateEntry(typeEntry.path, { icon, color })
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry])
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'color', color)
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry])
|
||||
|
||||
const handleReorderSections = useCallback((orderedTypes: { typeName: string; order: number }[]) => {
|
||||
for (const { typeName, order } of orderedTypes) {
|
||||
|
||||
@@ -389,6 +389,14 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
|
||||
const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName)), [persistNew])
|
||||
|
||||
/** Create a Type entry file silently (no tab opened). Adds to state and persists to disk. */
|
||||
const createTypeEntrySilent = useCallback(async (typeName: string): Promise<VaultEntry> => {
|
||||
const resolved = resolveNewType(typeName)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
await persistNewNote(resolved.entry.path, resolved.content)
|
||||
return resolved.entry
|
||||
}, [addEntry])
|
||||
|
||||
const fmCallbacks = { updateTab: updateTabContent, updateEntry, toast: setToastMessage }
|
||||
|
||||
const runFrontmatterOp = useCallback(
|
||||
@@ -426,6 +434,7 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
handleCreateNoteImmediate,
|
||||
handleOpenDailyNote,
|
||||
handleCreateType,
|
||||
createTypeEntrySilent,
|
||||
handleUpdateFrontmatter: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
||||
handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]),
|
||||
handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
||||
|
||||
@@ -7,6 +7,12 @@ vi.mock('../mock-tauri', () => ({
|
||||
isTauri: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
// Mock openExternalUrl
|
||||
const mockOpenExternalUrl = vi.fn()
|
||||
vi.mock('../utils/url', () => ({
|
||||
openExternalUrl: (...args: unknown[]) => mockOpenExternalUrl(...args),
|
||||
}))
|
||||
|
||||
// Mock the dynamic imports
|
||||
const mockCheck = vi.fn()
|
||||
const mockRelaunch = vi.fn()
|
||||
@@ -149,9 +155,8 @@ describe('useUpdater', () => {
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
it('openReleaseNotes opens the release notes URL', async () => {
|
||||
it('openReleaseNotes opens the release notes URL via openExternalUrl', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
|
||||
@@ -159,9 +164,8 @@ describe('useUpdater', () => {
|
||||
result.current.actions.openReleaseNotes()
|
||||
})
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://refactoringhq.github.io/laputa-app/',
|
||||
'_blank'
|
||||
expect(mockOpenExternalUrl).toHaveBeenCalledWith(
|
||||
'https://refactoringhq.github.io/laputa-app/'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
|
||||
const RELEASE_NOTES_URL = 'https://refactoringhq.github.io/laputa-app/'
|
||||
|
||||
@@ -80,7 +81,7 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
|
||||
}, [])
|
||||
|
||||
const openReleaseNotes = useCallback(() => {
|
||||
window.open(RELEASE_NOTES_URL, '_blank')
|
||||
openExternalUrl(RELEASE_NOTES_URL)
|
||||
}, [])
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user