diff --git a/src/App.tsx b/src/App.tsx index 9dce7e44..f631c5a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -69,6 +69,7 @@ import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, findNot import { GitRequiredModal } from './components/GitRequiredModal' import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner' import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents' +import type { NoteListMultiSelectionCommands } from './components/note-list/multiSelectionCommands' import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents' import { trackEvent } from './lib/telemetry' import { @@ -173,6 +174,7 @@ function App() { const layout = useLayoutPanels(noteWindowParams ? { initialInspectorCollapsed: true } : undefined) const { setInspectorCollapsed } = layout const visibleNotesRef = useRef([]) + const multiSelectionCommandRef = useRef(null) const [toastMessage, setToastMessage] = useState(null) const dialogs = useDialogs() const { showAIChat, toggleAIChat } = dialogs @@ -735,6 +737,7 @@ function App() { activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, entries: vault.entries, visibleNotesRef, + multiSelectionCommandRef, modifiedCount: vault.modifiedFiles.length, activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath), selection: effectiveSelection, @@ -888,7 +891,7 @@ function App() { {effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? ( setViewMode('all')} /> ) : ( - diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} /> + diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} /> )} diff --git a/src/components/NoteList.behavior.test.tsx b/src/components/NoteList.behavior.test.tsx index 9927fa5e..64e8a813 100644 --- a/src/components/NoteList.behavior.test.tsx +++ b/src/components/NoteList.behavior.test.tsx @@ -153,7 +153,7 @@ describe('NoteList multi-select', () => { { label: 'organizes via button', prop: 'onBulkOrganize', trigger: () => fireEvent.click(screen.getByTestId('bulk-organize-btn')) }, { label: 'archives via button', prop: 'onBulkArchive', trigger: () => fireEvent.click(screen.getByTestId('bulk-archive-btn')) }, { label: 'deletes via button', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.click(screen.getByTestId('bulk-delete-btn')) }, - { label: 'archives via Cmd+E', prop: 'onBulkArchive', trigger: () => fireEvent.keyDown(window, { key: 'e', metaKey: true }) }, + { label: 'organizes via Cmd+E', prop: 'onBulkOrganize', trigger: () => fireEvent.keyDown(window, { key: 'e', metaKey: true }) }, { label: 'deletes via Cmd+Backspace', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.keyDown(window, { key: 'Backspace', metaKey: true }) }, { label: 'deletes via Cmd+Delete', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.keyDown(window, { key: 'Delete', metaKey: true }) }, ])('bulk-select $label and clears the selection', ({ prop, trigger }) => { diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index c812ca15..32e8c63b 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -1,19 +1,52 @@ -import { memo, useCallback } from 'react' +import { memo, useCallback, useEffect } from 'react' import { NoteListLayout } from './note-list/NoteListLayout' import { useNoteListModel, type NoteListProps } from './note-list/useNoteListModel' +import type { NoteListMultiSelectionCommands } from './note-list/multiSelectionCommands' +import { useMultiSelectKeyboard } from './note-list/useMultiSelectKeyboard' type NoteListInnerProps = NoteListProps & { onBulkOrganize?: (paths: string[]) => void + multiSelectionCommandRef?: React.MutableRefObject } -function NoteListInner({ onBulkOrganize, ...props }: NoteListInnerProps) { +function NoteListInner({ onBulkOrganize, multiSelectionCommandRef, ...props }: NoteListInnerProps) { const model = useNoteListModel(props) + const handleBulkOrganize = useCallback(() => { const paths = [...model.multiSelect.selectedPaths] model.multiSelect.clear() onBulkOrganize?.(paths) }, [model.multiSelect, onBulkOrganize]) + useMultiSelectKeyboard({ + multiSelect: model.multiSelect, + isEntityView: model.isEntityView, + onBulkOrganize: onBulkOrganize ? handleBulkOrganize : undefined, + onBulkDelete: props.onBulkDeletePermanently ? model.handleBulkDeletePermanently : undefined, + enableActionShortcuts: !multiSelectionCommandRef, + }) + + useEffect(() => { + if (!multiSelectionCommandRef) return + + multiSelectionCommandRef.current = { + selectedPaths: [...model.multiSelect.selectedPaths], + deleteSelected: props.onBulkDeletePermanently ? model.handleBulkDeletePermanently : undefined, + organizeSelected: onBulkOrganize ? handleBulkOrganize : undefined, + } + + return () => { + multiSelectionCommandRef.current = null + } + }, [ + handleBulkOrganize, + model.handleBulkDeletePermanently, + model.multiSelect.selectedPaths, + multiSelectionCommandRef, + onBulkOrganize, + props.onBulkDeletePermanently, + ]) + return } diff --git a/src/components/note-list/multiSelectionCommands.ts b/src/components/note-list/multiSelectionCommands.ts new file mode 100644 index 00000000..1a91af64 --- /dev/null +++ b/src/components/note-list/multiSelectionCommands.ts @@ -0,0 +1,5 @@ +export interface NoteListMultiSelectionCommands { + selectedPaths: string[] + deleteSelected?: () => void + organizeSelected?: () => void +} diff --git a/src/components/note-list/useMultiSelectKeyboard.ts b/src/components/note-list/useMultiSelectKeyboard.ts new file mode 100644 index 00000000..4af24479 --- /dev/null +++ b/src/components/note-list/useMultiSelectKeyboard.ts @@ -0,0 +1,82 @@ +import { useEffect } from 'react' +import type { MultiSelectState } from '../../hooks/useMultiSelect' + +interface UseMultiSelectKeyboardOptions { + multiSelect: MultiSelectState + isEntityView: boolean + onBulkOrganize?: () => void + onBulkDelete?: () => void + enableActionShortcuts?: boolean +} + +function isInputFocused(): boolean { + const el = document.activeElement + return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || !!(el as HTMLElement)?.isContentEditable +} + +function usesCommandModifier(event: KeyboardEvent): boolean { + return event.metaKey || event.ctrlKey +} + +function clearSelectionOnEscape(event: KeyboardEvent, multiSelect: MultiSelectState) { + if (event.key !== 'Escape' || !multiSelect.isMultiSelecting) return + event.preventDefault() + multiSelect.clear() +} + +function selectVisibleNotes(event: KeyboardEvent, multiSelect: MultiSelectState, isEntityView: boolean) { + if (event.key !== 'a' || !(event.metaKey || event.ctrlKey) || isEntityView || isInputFocused()) return + event.preventDefault() + multiSelect.selectAll() +} + +function canRunBulkShortcut( + event: KeyboardEvent, + options: Pick, +): boolean { + return Boolean( + options.enableActionShortcuts + && !event.defaultPrevented + && options.multiSelect.isMultiSelecting + && usesCommandModifier(event), + ) +} + +function isOrganizeShortcut(event: KeyboardEvent, onBulkOrganize: (() => void) | undefined): onBulkOrganize is () => void { + return event.key === 'e' && !!onBulkOrganize +} + +function isDeleteShortcut(event: KeyboardEvent, onBulkDelete: (() => void) | undefined): onBulkDelete is () => void { + return (event.key === 'Backspace' || event.key === 'Delete') && !!onBulkDelete +} + +function runBulkShortcut( + event: KeyboardEvent, + options: Pick, +) { + if (!canRunBulkShortcut(event, options)) return + + if (isOrganizeShortcut(event, options.onBulkOrganize)) { + event.preventDefault() + options.onBulkOrganize() + return + } + + if (!isDeleteShortcut(event, options.onBulkDelete)) return + + event.preventDefault() + options.onBulkDelete() +} + +export function useMultiSelectKeyboard(options: UseMultiSelectKeyboardOptions) { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + clearSelectionOnEscape(event, options.multiSelect) + selectVisibleNotes(event, options.multiSelect, options.isEntityView) + runBulkShortcut(event, options) + } + + window.addEventListener('keydown', handleKeyDown, true) + return () => window.removeEventListener('keydown', handleKeyDown, true) + }, [options]) +} diff --git a/src/components/note-list/useNoteListModel.tsx b/src/components/note-list/useNoteListModel.tsx index 6dab6eb3..be1495b4 100644 --- a/src/components/note-list/useNoteListModel.tsx +++ b/src/components/note-list/useNoteListModel.tsx @@ -16,7 +16,6 @@ import { useChangeStatusResolver, useListPropertyPicker, useModifiedFilesState, - useMultiSelectKeyboard, useNoteListData, useNoteListInteractions, useNoteListSearch, @@ -207,9 +206,7 @@ export function useNoteListModel({ handleBulkArchive, handleBulkDeletePermanently, handleBulkUnarchive, - bulkArchiveOrUnarchive, } = useBulkActions(multiSelect, onBulkArchive, onBulkDeletePermanently, isArchivedView) - useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrUnarchive, handleBulkDeletePermanently) const renderItem = useCallback((entry: VaultEntry) => ( { expect(handlers.onDeleteNote).toHaveBeenCalledWith('/vault/test.md') }) + it('uses the current multi-selection for delete and organize commands', () => { + const handlers = makeHandlers() + const deleteSelected = vi.fn() + const organizeSelected = vi.fn() + handlers.multiSelectionCommandRef.current = { + selectedPaths: ['/vault/a.md', '/vault/b.md'], + deleteSelected, + organizeSelected, + } + + expect(dispatchAppCommand(APP_COMMAND_IDS.noteToggleOrganized, handlers)).toBe(true) + expect(dispatchAppCommand(APP_COMMAND_IDS.noteDelete, handlers)).toBe(true) + + expect(organizeSelected).toHaveBeenCalledTimes(1) + expect(deleteSelected).toHaveBeenCalledTimes(1) + expect(handlers.onToggleOrganized).not.toHaveBeenCalled() + expect(handlers.onDeleteNote).not.toHaveBeenCalled() + }) + + it('does not fall back to the active note when multi-selection cannot handle the command', () => { + const handlers = makeHandlers() + handlers.multiSelectionCommandRef.current = { + selectedPaths: ['/vault/a.md', '/vault/b.md'], + } + + expect(dispatchAppCommand(APP_COMMAND_IDS.noteToggleOrganized, handlers)).toBe(false) + expect(handlers.onToggleOrganized).not.toHaveBeenCalled() + }) + it('no-ops note-scoped commands when there is no active note', () => { const handlers = makeHandlers() handlers.activeTabPathRef.current = null diff --git a/src/hooks/appCommandDispatcher.ts b/src/hooks/appCommandDispatcher.ts index eeb7b91c..0097abef 100644 --- a/src/hooks/appCommandDispatcher.ts +++ b/src/hooks/appCommandDispatcher.ts @@ -6,6 +6,7 @@ import { type AppCommandDefinition, } from './appCommandCatalog' import type { ViewMode } from './useViewMode' +import type { NoteListMultiSelectionCommands } from '../components/note-list/multiSelectionCommands' export const APP_COMMAND_EVENT_NAME = 'laputa:dispatch-command' @@ -61,6 +62,7 @@ export interface AppCommandHandlers { onRepairVault?: () => void onRestoreDeletedNote?: () => void activeTabPathRef: MutableRefObject + multiSelectionCommandRef?: MutableRefObject } type SimpleHandlerKey = keyof Pick< @@ -180,6 +182,26 @@ function dispatchActiveTabCommand( return true } +function dispatchMultiSelectionCommand( + selectionRef: MutableRefObject | undefined, + handler: ActiveTabHandlerKey, +): boolean | null { + const selection = selectionRef?.current + if (!selection || selection.selectedPaths.length <= 1) return null + + if (handler === 'onDeleteNote') { + selection.deleteSelected?.() + return !!selection.deleteSelected + } + + if (handler === 'onToggleOrganized') { + selection.organizeSelected?.() + return !!selection.organizeSelected + } + + return false +} + function dispatchDefinition( definition: AppCommandDefinition, handlers: AppCommandHandlers, @@ -198,6 +220,14 @@ function dispatchDefinition( } case 'active-tab-handler': { const handler = definition.route.handler + const multiSelectionResult = dispatchMultiSelectionCommand( + handlers.multiSelectionCommandRef, + handler as ActiveTabHandlerKey, + ) + if (multiSelectionResult !== null) { + return multiSelectionResult + } + return dispatchActiveTabCommand( handlers.activeTabPathRef, (path) => ACTIVE_TAB_HANDLER_EXECUTORS[handler as ActiveTabHandlerKey](handlers, path), diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 3dbaae14..bc1b828d 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -9,12 +9,14 @@ import { useMenuEvents } from './useMenuEvents' import type { SidebarSelection, SidebarFilter, VaultEntry } from '../types' import type { NoteListFilter } from '../utils/noteListHelpers' import type { ViewMode } from './useViewMode' +import type { NoteListMultiSelectionCommands } from '../components/note-list/multiSelectionCommands' interface AppCommandsConfig { activeTabPath: string | null activeTabPathRef: React.MutableRefObject entries: VaultEntry[] visibleNotesRef: React.RefObject + multiSelectionCommandRef: React.MutableRefObject modifiedCount: number selection: SidebarSelection onQuickOpen: () => void @@ -108,6 +110,7 @@ function createKeyboardActions( onToggleOrganized: config.onToggleOrganized, onOpenInNewWindow: config.onOpenInNewWindow, activeTabPathRef: config.activeTabPathRef, + multiSelectionCommandRef: config.multiSelectionCommandRef, } } @@ -151,6 +154,7 @@ function createMenuEventHandlers( onOpenInNewWindow: config.onOpenInNewWindow, onRestoreDeletedNote: config.onRestoreDeletedNote, activeTabPathRef: config.activeTabPathRef, + multiSelectionCommandRef: config.multiSelectionCommandRef, activeTabPath: config.activeTabPath, modifiedCount: config.modifiedCount, hasRestorableDeletedNote: config.canRestoreDeletedNote, diff --git a/src/hooks/useAppKeyboard.test.ts b/src/hooks/useAppKeyboard.test.ts index 7d47e566..4f4b3c43 100644 --- a/src/hooks/useAppKeyboard.test.ts +++ b/src/hooks/useAppKeyboard.test.ts @@ -44,6 +44,7 @@ function makeActions() { onZoomOut: vi.fn(), onZoomReset: vi.fn(), activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject, + multiSelectionCommandRef: { current: null }, } } @@ -156,6 +157,21 @@ describe('useAppKeyboard', () => { expect(actions.onArchiveNote).not.toHaveBeenCalled() }) + it('Cmd+E uses the current multi-selection instead of the active note', () => { + const actions = makeActions() + const organizeSelected = vi.fn() + actions.multiSelectionCommandRef.current = { + selectedPaths: ['/vault/a.md', '/vault/b.md'], + organizeSelected, + } + + renderHook(() => useAppKeyboard(actions)) + fireKey('e', { metaKey: true }) + + expect(organizeSelected).toHaveBeenCalledTimes(1) + expect(actions.onToggleOrganized).not.toHaveBeenCalled() + }) + it('Cmd+E still works when editor focus stops propagation', () => { const actions = makeActions() const onToggleOrganized = vi.fn() @@ -245,6 +261,21 @@ describe('useAppKeyboard', () => { expect(actions.onDeleteNote).toHaveBeenCalledWith('/vault/test.md') }) + it('Cmd+Backspace deletes the current multi-selection instead of the active note', () => { + const actions = makeActions() + const deleteSelected = vi.fn() + actions.multiSelectionCommandRef.current = { + selectedPaths: ['/vault/a.md', '/vault/b.md'], + deleteSelected, + } + + renderHook(() => useAppKeyboard(actions)) + fireKey('Backspace', { metaKey: true }) + + expect(deleteSelected).toHaveBeenCalledTimes(1) + expect(actions.onDeleteNote).not.toHaveBeenCalled() + }) + it('Cmd+K still works when text input is focused', () => { const actions = makeActions() renderHook(() => useAppKeyboard(actions)) diff --git a/src/hooks/useMenuEvents.test.ts b/src/hooks/useMenuEvents.test.ts index 49802a20..edbcfc28 100644 --- a/src/hooks/useMenuEvents.test.ts +++ b/src/hooks/useMenuEvents.test.ts @@ -54,6 +54,7 @@ function makeHandlers(): MenuEventHandlers { onOpenInNewWindow: vi.fn(), onRestoreDeletedNote: vi.fn(), activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject, + multiSelectionCommandRef: { current: null }, activeTabPath: '/vault/test.md', hasRestorableDeletedNote: false, } @@ -197,6 +198,20 @@ describe('dispatchMenuEvent', () => { expect(h.onToggleOrganized).toHaveBeenCalledWith('/vault/test.md') }) + it('note-toggle-organized uses the current multi-selection when available', () => { + const h = makeHandlers() + const organizeSelected = vi.fn() + h.multiSelectionCommandRef.current = { + selectedPaths: ['/vault/a.md', '/vault/b.md'], + organizeSelected, + } + + dispatchMenuEvent('note-toggle-organized', h) + + expect(organizeSelected).toHaveBeenCalledTimes(1) + expect(h.onToggleOrganized).not.toHaveBeenCalled() + }) + it('note-toggle-organized does nothing when no active tab', () => { const h = makeHandlers() h.activeTabPathRef = { current: null } @@ -210,6 +225,20 @@ describe('dispatchMenuEvent', () => { expect(h.onDeleteNote).toHaveBeenCalledWith('/vault/test.md') }) + it('note-delete uses the current multi-selection when available', () => { + const h = makeHandlers() + const deleteSelected = vi.fn() + h.multiSelectionCommandRef.current = { + selectedPaths: ['/vault/a.md', '/vault/b.md'], + deleteSelected, + } + + dispatchMenuEvent('note-delete', h) + + expect(deleteSelected).toHaveBeenCalledTimes(1) + expect(h.onDeleteNote).not.toHaveBeenCalled() + }) + it('note-delete does nothing when no active tab', () => { const h = makeHandlers() h.activeTabPathRef = { current: null } diff --git a/tests/smoke/multi-selection-shortcuts.spec.ts b/tests/smoke/multi-selection-shortcuts.spec.ts new file mode 100644 index 00000000..0e97d6bd --- /dev/null +++ b/tests/smoke/multi-selection-shortcuts.spec.ts @@ -0,0 +1,75 @@ +import { test, expect, type Page } from '@playwright/test' +import { + createFixtureVaultCopy, + openFixtureVaultDesktopHarness, + removeFixtureVaultCopy, +} from '../helpers/fixtureVault' +import { dispatchShortcutEvent } from './testBridge' + +const USE_META_SHORTCUTS = process.platform === 'darwin' + +let tempVaultDir: string + +async function focusNoteList(page: Page) { + const container = page.getByTestId('note-list-container') + await container.focus() + await expect(container).toBeFocused() +} + +async function dispatchCommandShortcut(page: Page, key: string, code: string) { + await dispatchShortcutEvent(page, { + key, + code, + metaKey: USE_META_SHORTCUTS, + ctrlKey: !USE_META_SHORTCUTS, + shiftKey: false, + altKey: false, + bubbles: true, + cancelable: true, + }) +} + +async function selectVisibleInboxBatch(page: Page) { + await focusNoteList(page) + await dispatchCommandShortcut(page, 'a', 'KeyA') + await expect(page.getByTestId('bulk-action-bar')).toBeVisible({ timeout: 5_000 }) +} + +test.describe('multi-selection shortcuts', () => { + test.beforeEach(() => { + tempVaultDir = createFixtureVaultCopy() + }) + + test.afterEach(() => { + removeFixtureVaultCopy(tempVaultDir) + }) + + test('Cmd/Ctrl+E organizes the full Inbox multi-selection @smoke', async ({ page }) => { + await openFixtureVaultDesktopHarness(page, tempVaultDir) + await selectVisibleInboxBatch(page) + + await dispatchCommandShortcut(page, 'e', 'KeyE') + + await expect(page.getByTestId('bulk-action-bar')).toHaveCount(0) + await expect(page.getByText('All notes are organized')).toBeVisible({ timeout: 5_000 }) + }) + + test('Cmd/Ctrl+Backspace batch-deletes the full visible multi-selection after one confirmation @smoke', async ({ page }) => { + await openFixtureVaultDesktopHarness(page, tempVaultDir) + await selectVisibleInboxBatch(page) + + await dispatchCommandShortcut(page, 'Backspace', 'Backspace') + + const dialog = page.getByTestId('confirm-delete-dialog') + const confirmButton = page.getByTestId('confirm-delete-btn') + await expect(dialog).toBeVisible({ timeout: 5_000 }) + await expect(dialog).toContainText(/Delete \d+ notes permanently\?/) + + await page.keyboard.press('Tab') + await expect(confirmButton).toBeFocused() + await page.keyboard.press('Enter') + + await expect(dialog).toHaveCount(0) + await expect(page.getByTestId('bulk-action-bar')).toHaveCount(0) + }) +})