Compare commits

...

3 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
6 changed files with 83 additions and 16 deletions

View File

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

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

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