Compare commits

...

4 Commits

Author SHA1 Message Date
Luca Rossi
32b8e2ee57 feat: toggle archive/trash shortcuts — Cmd+E unarchives, Cmd+⌫ restores (#175)
Cmd+E and Cmd+Delete now toggle: archiving a normal note or unarchiving
an archived one, trashing a normal note or restoring a trashed one.
Command palette labels update to reflect the current state.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:55:51 +01:00
Luca Rossi
b1110ead87 fix: back/forward nav arrows reopen replaced tabs instead of skipping them (#174)
handleReplaceActiveTab removes the old tab from the tabs array, but the
old path remained in the navigation history. goBack(isTabOpen) then
skipped it because the tab was no longer open, returning null and making
the buttons appear broken.

Fix: filter by vault entry existence (not tab-open state) and reopen
closed tabs via handleSelectNote when navigating back/forward.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 11:37:20 +01:00
Luca Rossi
b75b917538 fix: use openExternalUrl for release notes link in Tauri app (#171)
window.open() doesn't open URLs in the system browser from Tauri's
WebView. Switch to the existing openExternalUrl utility which uses
@tauri-apps/plugin-opener in native mode.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 11:36:44 +01:00
Luca Rossi
24bb64841e fix: customize icon & color works for types without Type entry (#173)
Types without a dedicated Type definition note in the vault silently
failed to save icon/color customizations. Both applyCustomization
(Sidebar) and handleCustomizeType (useEntryActions) returned early
when no Type entry existed. Also fixed a race condition where two
concurrent handleUpdateFrontmatter calls could overwrite each other.

Changes:
- useNoteActions: add createTypeEntrySilent for headless Type file creation
- useEntryActions: auto-create Type entry when missing, serialize writes
- Sidebar: applyCustomization proceeds with defaults when no Type entry
- App: wire createTypeEntrySilent into useEntryActions config

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:22:03 +01:00
10 changed files with 134 additions and 33 deletions

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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'])
})
})

View File

@@ -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) {

View File

@@ -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]),

View File

@@ -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/'
)
})

View File

@@ -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(() => {