diff --git a/src/App.tsx b/src/App.tsx index 2ad83056..46840707 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1704,7 +1704,7 @@ function App() { {effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? ( handleSetViewMode('all')} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.historyRepositoryPath} onRepositoryChange={gitSurfaces.setHistoryRepositoryPath} locale={appLocale} /> ) : ( - + )} diff --git a/src/components/NoteList.contextMenu.test.tsx b/src/components/NoteList.contextMenu.test.tsx index 125e43d3..d4145404 100644 --- a/src/components/NoteList.contextMenu.test.tsx +++ b/src/components/NoteList.contextMenu.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { mockEntries, renderNoteList } from '../test-utils/noteListTestUtils' +import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../hooks/appCommandCatalog' +import { makeEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils' describe('NoteList context menu', () => { it('opens note actions from a right-clicked note item', () => { @@ -8,24 +9,53 @@ describe('NoteList context menu', () => { const onEnterNeighborhood = vi.fn() const onBulkArchive = vi.fn() const onBulkDeletePermanently = vi.fn() + const onToggleFavorite = vi.fn() + const onToggleOrganized = vi.fn() + const onRevealFile = vi.fn() + const onCopyFilePath = vi.fn() renderNoteList({ onOpenInNewWindow, onEnterNeighborhood, onBulkArchive, onBulkDeletePermanently, + onToggleFavorite, + onToggleOrganized, + onRevealFile, + onCopyFilePath, }) fireEvent.contextMenu(screen.getByText('Build Laputa App')) expect(screen.getByTestId('note-list-context-menu')).toBeInTheDocument() + expect(screen.getByText(getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteOpenInNewWindow)!)).toBeInTheDocument() + expect(screen.getByText(getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteToggleFavorite)!)).toBeInTheDocument() + expect(screen.getByText(getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteToggleOrganized)!)).toBeInTheDocument() + expect(screen.getByText(getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteDelete)!)).toBeInTheDocument() + fireEvent.click(screen.getByText('Open in New Window')) expect(onOpenInNewWindow).toHaveBeenCalledWith(mockEntries[0]) + fireEvent.contextMenu(screen.getByText('Build Laputa App')) + fireEvent.click(screen.getByText('Add to Favorites')) + expect(onToggleFavorite).toHaveBeenCalledWith(mockEntries[0].path) + + fireEvent.contextMenu(screen.getByText('Build Laputa App')) + fireEvent.click(screen.getByText('Mark as Organized')) + expect(onToggleOrganized).toHaveBeenCalledWith(mockEntries[0].path) + fireEvent.contextMenu(screen.getByText('Build Laputa App')) fireEvent.click(screen.getByText("Open note's neighborhood")) expect(onEnterNeighborhood).toHaveBeenCalledWith(mockEntries[0]) + fireEvent.contextMenu(screen.getByText('Build Laputa App')) + fireEvent.click(screen.getByText('Reveal in Finder')) + expect(onRevealFile).toHaveBeenCalledWith(mockEntries[0].path) + + fireEvent.contextMenu(screen.getByText('Build Laputa App')) + fireEvent.click(screen.getByText('Copy file path')) + expect(onCopyFilePath).toHaveBeenCalledWith(mockEntries[0].path) + fireEvent.contextMenu(screen.getByText('Build Laputa App')) fireEvent.click(screen.getByText('Archive this note')) expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path]) @@ -34,4 +64,24 @@ describe('NoteList context menu', () => { fireEvent.click(screen.getByText('Delete this note')) expect(onBulkDeletePermanently).toHaveBeenCalledWith([mockEntries[0].path]) }) + + it('shows stateful favorite and organized labels for pinned notes', () => { + renderNoteList({ + entries: [ + makeEntry({ + favorite: true, + organized: true, + path: '/vault/stateful.md', + title: 'Stateful Note', + }), + ], + onToggleFavorite: vi.fn(), + onToggleOrganized: vi.fn(), + }) + + fireEvent.contextMenu(screen.getByText('Stateful Note')) + + expect(screen.getByText('Remove from Favorites')).toBeInTheDocument() + expect(screen.getByText('Mark as Unorganized')).toBeInTheDocument() + }) }) diff --git a/src/components/note-list/NoteListContextMenu.tsx b/src/components/note-list/NoteListContextMenu.tsx index 02d7ccb4..0ce9717a 100644 --- a/src/components/note-list/NoteListContextMenu.tsx +++ b/src/components/note-list/NoteListContextMenu.tsx @@ -22,6 +22,10 @@ interface NoteListContextMenuParams { onOpenInNewWindow?: (entry: VaultEntry) => void onArchivePaths?: (paths: string[]) => void onDeletePaths?: (paths: string[]) => void + onToggleFavorite?: (path: string) => void + onToggleOrganized?: (path: string) => void + onRevealFile?: (path: string) => void + onCopyFilePath?: (path: string) => void } function hasNoteListContextActions({ @@ -30,13 +34,21 @@ function hasNoteListContextActions({ onOpenInNewWindow, onArchivePaths, onDeletePaths, + onToggleFavorite, + onToggleOrganized, + onRevealFile, + onCopyFilePath, }: NoteListContextMenuParams & { entry: VaultEntry }) { - return Boolean( - onOpenInNewWindow - || (onEnterNeighborhood && entry.fileKind !== 'binary') - || (onArchivePaths && !entry.archived) - || onDeletePaths, - ) + return [ + onOpenInNewWindow, + onEnterNeighborhood && entry.fileKind !== 'binary', + onArchivePaths && !entry.archived, + onDeletePaths, + onToggleFavorite, + onToggleOrganized, + onRevealFile, + onCopyFilePath, + ].some(Boolean) } export function useNoteListContextMenu({ @@ -45,6 +57,10 @@ export function useNoteListContextMenu({ onOpenInNewWindow, onArchivePaths, onDeletePaths, + onToggleFavorite, + onToggleOrganized, + onRevealFile, + onCopyFilePath, }: NoteListContextMenuParams) { const [ctxMenu, setCtxMenu] = useState(null) const ctxMenuRef = useRef(null) @@ -69,12 +85,31 @@ export function useNoteListContextMenu({ }, [ctxMenu, closeContextMenu]) const handleNoteContextMenu = useCallback((entry: VaultEntry, event: ReactMouseEvent) => { - if (!hasNoteListContextActions({ entry, onEnterNeighborhood, onOpenInNewWindow, onArchivePaths, onDeletePaths })) return + if (!hasNoteListContextActions({ + entry, + onEnterNeighborhood, + onOpenInNewWindow, + onArchivePaths, + onDeletePaths, + onToggleFavorite, + onToggleOrganized, + onRevealFile, + onCopyFilePath, + })) return event.preventDefault() event.stopPropagation() trackEvent('note_item_context_menu_opened') setCtxMenu({ x: event.clientX, y: event.clientY, entry }) - }, [onArchivePaths, onDeletePaths, onEnterNeighborhood, onOpenInNewWindow]) + }, [ + onArchivePaths, + onCopyFilePath, + onDeletePaths, + onEnterNeighborhood, + onOpenInNewWindow, + onRevealFile, + onToggleFavorite, + onToggleOrganized, + ]) const contextMenuNode = ( ) diff --git a/src/components/note-list/NoteListContextMenuView.tsx b/src/components/note-list/NoteListContextMenuView.tsx index 68b9951f..86a7f958 100644 --- a/src/components/note-list/NoteListContextMenuView.tsx +++ b/src/components/note-list/NoteListContextMenuView.tsx @@ -1,11 +1,213 @@ import type { RefObject } from 'react' -import { Archive, ArrowSquareOut, MapTrifold, Trash } from '@phosphor-icons/react' +import { + Archive, + ArrowSquareOut, + CheckCircle, + ClipboardText, + FolderOpen, + MapTrifold, + Star, + Trash, + type Icon, +} from '@phosphor-icons/react' import { Button } from '@/components/ui/button' +import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../../hooks/appCommandCatalog' import { translate, type AppLocale } from '../../lib/i18n' import { trackEvent } from '../../lib/telemetry' import type { VaultEntry } from '../../types' import type { NoteListContextMenuState } from './NoteListContextMenu' +interface NoteListContextMenuItem { + destructive?: boolean + icon: Icon + iconWeight?: 'bold' | 'fill' | 'regular' + label: string + onSelect: () => void + shortcut?: string +} + +type SelectContextAction = (action: string, run: () => void) => void + +interface NoteListContextMenuNodeProps { + ctxMenu: NoteListContextMenuState | null + ctxMenuRef: RefObject + locale: AppLocale + onEnterNeighborhood?: (entry: VaultEntry) => void + onOpenInNewWindow?: (entry: VaultEntry) => void + onArchivePaths?: (paths: string[]) => void + onDeletePaths?: (paths: string[]) => void + onToggleFavorite?: (path: string) => void + onToggleOrganized?: (path: string) => void + onRevealFile?: (path: string) => void + onCopyFilePath?: (path: string) => void + onClose: () => void +} + +type BuildContextMenuItemsParams = Pick< + NoteListContextMenuNodeProps, + | 'locale' + | 'onEnterNeighborhood' + | 'onOpenInNewWindow' + | 'onArchivePaths' + | 'onDeletePaths' + | 'onToggleFavorite' + | 'onToggleOrganized' + | 'onRevealFile' + | 'onCopyFilePath' +> + +function openWindowItem( + entry: VaultEntry, + locale: AppLocale, + onOpenInNewWindow: ((entry: VaultEntry) => void) | undefined, + selectAction: SelectContextAction, +) { + if (!onOpenInNewWindow) return [] + return [{ + icon: ArrowSquareOut, + label: translate(locale, 'command.note.openNewWindow'), + onSelect: () => selectAction('open_new_window', () => onOpenInNewWindow(entry)), + shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteOpenInNewWindow), + }] +} + +function favoriteItem( + entry: VaultEntry, + locale: AppLocale, + onToggleFavorite: ((path: string) => void) | undefined, + selectAction: SelectContextAction, +) { + if (!onToggleFavorite) return [] + return [{ + icon: Star, + iconWeight: entry.favorite ? 'fill' as const : 'regular' as const, + label: translate(locale, entry.favorite ? 'command.note.removeFavorite' : 'command.note.addFavorite'), + onSelect: () => selectAction('toggle_favorite', () => onToggleFavorite(entry.path)), + shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteToggleFavorite), + }] +} + +function organizedItem( + entry: VaultEntry, + locale: AppLocale, + onToggleOrganized: ((path: string) => void) | undefined, + selectAction: SelectContextAction, +) { + if (!onToggleOrganized) return [] + return [{ + icon: CheckCircle, + iconWeight: entry.organized ? 'fill' as const : 'regular' as const, + label: translate(locale, entry.organized ? 'command.note.markUnorganized' : 'command.note.markOrganized'), + onSelect: () => selectAction('toggle_organized', () => onToggleOrganized(entry.path)), + shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteToggleOrganized), + }] +} + +function neighborhoodItem( + entry: VaultEntry, + locale: AppLocale, + onEnterNeighborhood: ((entry: VaultEntry) => void) | undefined, + selectAction: SelectContextAction, +) { + if (!onEnterNeighborhood || entry.fileKind === 'binary') return [] + return [{ + icon: MapTrifold, + label: translate(locale, 'editor.toolbar.openNeighborhood'), + onSelect: () => selectAction('open_neighborhood', () => onEnterNeighborhood(entry)), + }] +} + +function revealFileItem( + entry: VaultEntry, + locale: AppLocale, + onRevealFile: ((path: string) => void) | undefined, + selectAction: SelectContextAction, +) { + if (!onRevealFile) return [] + return [{ + icon: FolderOpen, + label: translate(locale, 'editor.toolbar.revealFile'), + onSelect: () => selectAction('reveal_file', () => onRevealFile(entry.path)), + }] +} + +function copyFilePathItem( + entry: VaultEntry, + locale: AppLocale, + onCopyFilePath: ((path: string) => void) | undefined, + selectAction: SelectContextAction, +) { + if (!onCopyFilePath) return [] + return [{ + icon: ClipboardText, + label: translate(locale, 'editor.toolbar.copyFilePath'), + onSelect: () => selectAction('copy_file_path', () => onCopyFilePath(entry.path)), + }] +} + +function archiveItem( + entry: VaultEntry, + locale: AppLocale, + onArchivePaths: ((paths: string[]) => void) | undefined, + selectAction: SelectContextAction, +) { + if (!onArchivePaths || entry.archived) return [] + return [{ + icon: Archive, + label: translate(locale, 'editor.toolbar.archive'), + onSelect: () => selectAction('archive', () => onArchivePaths([entry.path])), + }] +} + +function deleteItem( + entry: VaultEntry, + locale: AppLocale, + onDeletePaths: ((paths: string[]) => void) | undefined, + selectAction: SelectContextAction, +) { + if (!onDeletePaths) return [] + return [{ + destructive: true, + icon: Trash, + label: translate(locale, 'editor.toolbar.delete'), + onSelect: () => selectAction('delete', () => onDeletePaths([entry.path])), + shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteDelete), + }] +} + +function buildContextMenuItems( + props: BuildContextMenuItemsParams, + entry: VaultEntry, + selectAction: SelectContextAction, +): NoteListContextMenuItem[] { + return [ + ...openWindowItem(entry, props.locale, props.onOpenInNewWindow, selectAction), + ...favoriteItem(entry, props.locale, props.onToggleFavorite, selectAction), + ...organizedItem(entry, props.locale, props.onToggleOrganized, selectAction), + ...neighborhoodItem(entry, props.locale, props.onEnterNeighborhood, selectAction), + ...revealFileItem(entry, props.locale, props.onRevealFile, selectAction), + ...copyFilePathItem(entry, props.locale, props.onCopyFilePath, selectAction), + ...archiveItem(entry, props.locale, props.onArchivePaths, selectAction), + ...deleteItem(entry, props.locale, props.onDeletePaths, selectAction), + ] +} + +function NoteListContextMenuButton({ item }: { item: NoteListContextMenuItem }) { + const IconComponent = item.icon + return ( + + ) +} + export function NoteListContextMenuNode({ ctxMenu, ctxMenuRef, @@ -14,17 +216,12 @@ export function NoteListContextMenuNode({ onOpenInNewWindow, onArchivePaths, onDeletePaths, + onToggleFavorite, + onToggleOrganized, + onRevealFile, + onCopyFilePath, onClose, -}: { - ctxMenu: NoteListContextMenuState | null - ctxMenuRef: RefObject - locale: AppLocale - onEnterNeighborhood?: (entry: VaultEntry) => void - onOpenInNewWindow?: (entry: VaultEntry) => void - onArchivePaths?: (paths: string[]) => void - onDeletePaths?: (paths: string[]) => void - onClose: () => void -}) { +}: NoteListContextMenuNodeProps) { if (!ctxMenu) return null const { entry } = ctxMenu @@ -33,58 +230,26 @@ export function NoteListContextMenuNode({ onClose() run() } + const items = buildContextMenuItems({ + locale, + onEnterNeighborhood, + onOpenInNewWindow, + onArchivePaths, + onDeletePaths, + onToggleFavorite, + onToggleOrganized, + onRevealFile, + onCopyFilePath, + }, entry, selectAction) return (
- {onOpenInNewWindow && ( - - )} - {onEnterNeighborhood && entry.fileKind !== 'binary' && ( - - )} - {onArchivePaths && !entry.archived && ( - - )} - {onDeletePaths && ( - - )} + {items.map((item) => )}
) } diff --git a/src/components/note-list/useNoteListModel.tsx b/src/components/note-list/useNoteListModel.tsx index 90749094..176da6bc 100644 --- a/src/components/note-list/useNoteListModel.tsx +++ b/src/components/note-list/useNoteListModel.tsx @@ -306,6 +306,10 @@ interface UseNoteListInteractionStateParams { onEnterNeighborhood?: (entry: VaultEntry) => void onOpenDeletedNote?: (entry: DeletedNoteEntry) => void onOpenInNewWindow?: (entry: VaultEntry) => void + onToggleFavorite?: (path: string) => void + onToggleOrganized?: (path: string) => void + onRevealFile?: (path: string) => void + onCopyFilePath?: (path: string) => void onAutoTriggerDiff?: () => void onDiscardFile?: (relativePath: string) => Promise onCreateNote: (type?: string) => void @@ -330,6 +334,10 @@ function useNoteListInteractionState({ onEnterNeighborhood, onOpenDeletedNote, onOpenInNewWindow, + onToggleFavorite, + onToggleOrganized, + onRevealFile, + onCopyFilePath, onAutoTriggerDiff, onDiscardFile, onCreateNote, @@ -344,6 +352,10 @@ function useNoteListInteractionState({ onOpenInNewWindow, onArchivePaths: onBulkArchive, onDeletePaths: onBulkDeletePermanently, + onToggleFavorite, + onToggleOrganized, + onRevealFile, + onCopyFilePath, }) const { collapsedGroups, @@ -501,6 +513,10 @@ export interface NoteListProps { onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void updateEntry?: (path: string, patch: Partial) => void onOpenInNewWindow?: (entry: VaultEntry) => void + onToggleFavorite?: (path: string) => void + onToggleOrganized?: (path: string) => void + onRevealFile?: (path: string) => void + onCopyFilePath?: (path: string) => void onDiscardFile?: (relativePath: string) => Promise onAutoTriggerDiff?: () => void onOpenDeletedNote?: (entry: DeletedNoteEntry) => void @@ -625,6 +641,10 @@ export function useNoteListModel({ onUpdateTypeSort, updateEntry, onOpenInNewWindow, + onToggleFavorite, + onToggleOrganized, + onRevealFile, + onCopyFilePath, onDiscardFile, onAutoTriggerDiff, onOpenDeletedNote, @@ -679,6 +699,10 @@ export function useNoteListModel({ onEnterNeighborhood, onOpenDeletedNote, onOpenInNewWindow, + onToggleFavorite, + onToggleOrganized, + onRevealFile, + onCopyFilePath, onAutoTriggerDiff, onDiscardFile, onCreateNote,