Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim
This commit is contained in:
37
src/components/NoteList.contextMenu.test.tsx
Normal file
37
src/components/NoteList.contextMenu.test.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
|
||||
|
||||
describe('NoteList context menu', () => {
|
||||
it('opens note actions from a right-clicked note item', () => {
|
||||
const onOpenInNewWindow = vi.fn()
|
||||
const onEnterNeighborhood = vi.fn()
|
||||
const onBulkArchive = vi.fn()
|
||||
const onBulkDeletePermanently = vi.fn()
|
||||
|
||||
renderNoteList({
|
||||
onOpenInNewWindow,
|
||||
onEnterNeighborhood,
|
||||
onBulkArchive,
|
||||
onBulkDeletePermanently,
|
||||
})
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('Build Laputa App'))
|
||||
|
||||
expect(screen.getByTestId('note-list-context-menu')).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("Open note's neighborhood"))
|
||||
expect(onEnterNeighborhood).toHaveBeenCalledWith(mockEntries[0])
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Archive this note'))
|
||||
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path])
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Delete this note'))
|
||||
expect(onBulkDeletePermanently).toHaveBeenCalledWith([mockEntries[0].path])
|
||||
})
|
||||
})
|
||||
96
src/components/note-list/NoteListContextMenu.tsx
Normal file
96
src/components/note-list/NoteListContextMenu.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react'
|
||||
import type { AppLocale } from '../../lib/i18n'
|
||||
import { trackEvent } from '../../lib/telemetry'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { NoteListContextMenuNode } from './NoteListContextMenuView'
|
||||
|
||||
export type NoteListContextMenuState = {
|
||||
x: number
|
||||
y: number
|
||||
entry: VaultEntry
|
||||
}
|
||||
|
||||
interface NoteListContextMenuParams {
|
||||
locale?: AppLocale
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void
|
||||
onOpenInNewWindow?: (entry: VaultEntry) => void
|
||||
onArchivePaths?: (paths: string[]) => void
|
||||
onDeletePaths?: (paths: string[]) => void
|
||||
}
|
||||
|
||||
function hasNoteListContextActions({
|
||||
entry,
|
||||
onEnterNeighborhood,
|
||||
onOpenInNewWindow,
|
||||
onArchivePaths,
|
||||
onDeletePaths,
|
||||
}: NoteListContextMenuParams & { entry: VaultEntry }) {
|
||||
return Boolean(
|
||||
onOpenInNewWindow
|
||||
|| (onEnterNeighborhood && entry.fileKind !== 'binary')
|
||||
|| (onArchivePaths && !entry.archived)
|
||||
|| onDeletePaths,
|
||||
)
|
||||
}
|
||||
|
||||
export function useNoteListContextMenu({
|
||||
locale = 'en',
|
||||
onEnterNeighborhood,
|
||||
onOpenInNewWindow,
|
||||
onArchivePaths,
|
||||
onDeletePaths,
|
||||
}: NoteListContextMenuParams) {
|
||||
const [ctxMenu, setCtxMenu] = useState<NoteListContextMenuState | null>(null)
|
||||
const ctxMenuRef = useRef<HTMLDivElement>(null)
|
||||
const closeContextMenu = useCallback(() => setCtxMenu(null), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctxMenu) return
|
||||
|
||||
const handleOutsideClick = (event: MouseEvent) => {
|
||||
if (ctxMenuRef.current && !ctxMenuRef.current.contains(event.target as Node)) closeContextMenu()
|
||||
}
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') closeContextMenu()
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [ctxMenu, closeContextMenu])
|
||||
|
||||
const handleNoteContextMenu = useCallback((entry: VaultEntry, event: ReactMouseEvent) => {
|
||||
if (!hasNoteListContextActions({ entry, onEnterNeighborhood, onOpenInNewWindow, onArchivePaths, onDeletePaths })) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
trackEvent('note_item_context_menu_opened')
|
||||
setCtxMenu({ x: event.clientX, y: event.clientY, entry })
|
||||
}, [onArchivePaths, onDeletePaths, onEnterNeighborhood, onOpenInNewWindow])
|
||||
|
||||
const contextMenuNode = (
|
||||
<NoteListContextMenuNode
|
||||
ctxMenu={ctxMenu}
|
||||
ctxMenuRef={ctxMenuRef}
|
||||
locale={locale}
|
||||
onEnterNeighborhood={onEnterNeighborhood}
|
||||
onOpenInNewWindow={onOpenInNewWindow}
|
||||
onArchivePaths={onArchivePaths}
|
||||
onDeletePaths={onDeletePaths}
|
||||
onClose={closeContextMenu}
|
||||
/>
|
||||
)
|
||||
|
||||
return {
|
||||
handleNoteContextMenu,
|
||||
contextMenuNode,
|
||||
}
|
||||
}
|
||||
90
src/components/note-list/NoteListContextMenuView.tsx
Normal file
90
src/components/note-list/NoteListContextMenuView.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { RefObject } from 'react'
|
||||
import { Archive, ArrowSquareOut, MapTrifold, Trash } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate, type AppLocale } from '../../lib/i18n'
|
||||
import { trackEvent } from '../../lib/telemetry'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import type { NoteListContextMenuState } from './NoteListContextMenu'
|
||||
|
||||
export function NoteListContextMenuNode({
|
||||
ctxMenu,
|
||||
ctxMenuRef,
|
||||
locale,
|
||||
onEnterNeighborhood,
|
||||
onOpenInNewWindow,
|
||||
onArchivePaths,
|
||||
onDeletePaths,
|
||||
onClose,
|
||||
}: {
|
||||
ctxMenu: NoteListContextMenuState | null
|
||||
ctxMenuRef: RefObject<HTMLDivElement | null>
|
||||
locale: AppLocale
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void
|
||||
onOpenInNewWindow?: (entry: VaultEntry) => void
|
||||
onArchivePaths?: (paths: string[]) => void
|
||||
onDeletePaths?: (paths: string[]) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
if (!ctxMenu) return null
|
||||
|
||||
const { entry } = ctxMenu
|
||||
const selectAction = (action: string, run: () => void) => {
|
||||
trackEvent('note_item_context_menu_action', { action })
|
||||
onClose()
|
||||
run()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ctxMenuRef}
|
||||
className="fixed z-50 rounded-md border bg-popover p-1 shadow-md"
|
||||
style={{ left: ctxMenu.x, top: ctxMenu.y, minWidth: 210 }}
|
||||
data-testid="note-list-context-menu"
|
||||
>
|
||||
{onOpenInNewWindow && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="flex h-auto w-full cursor-default items-center justify-start gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => selectAction('open_new_window', () => onOpenInNewWindow(entry))}
|
||||
>
|
||||
<ArrowSquareOut size={16} />
|
||||
{translate(locale, 'command.note.openNewWindow')}
|
||||
</Button>
|
||||
)}
|
||||
{onEnterNeighborhood && entry.fileKind !== 'binary' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="flex h-auto w-full cursor-default items-center justify-start gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => selectAction('open_neighborhood', () => onEnterNeighborhood(entry))}
|
||||
>
|
||||
<MapTrifold size={16} />
|
||||
{translate(locale, 'editor.toolbar.openNeighborhood')}
|
||||
</Button>
|
||||
)}
|
||||
{onArchivePaths && !entry.archived && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="flex h-auto w-full cursor-default items-center justify-start gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => selectAction('archive', () => onArchivePaths([entry.path]))}
|
||||
>
|
||||
<Archive size={16} />
|
||||
{translate(locale, 'editor.toolbar.archive')}
|
||||
</Button>
|
||||
)}
|
||||
{onDeletePaths && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="flex h-auto w-full cursor-default items-center justify-start gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-destructive transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => selectAction('delete', () => onDeletePaths([entry.path]))}
|
||||
>
|
||||
<Trash size={16} />
|
||||
{translate(locale, 'editor.toolbar.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
useVisibleNotesSync,
|
||||
} from './noteListHooks'
|
||||
import { useChangesContextMenu } from './NoteListChangesMenu'
|
||||
import { useNoteListContextMenu } from './NoteListContextMenu'
|
||||
import { addNoteListSearchToggleListener, dispatchNoteListSearchAvailability } from '../../utils/noteListSearchEvents'
|
||||
import { useDateDisplayFormat } from '../../hooks/useAppPreferences'
|
||||
|
||||
@@ -337,6 +338,13 @@ function useNoteListInteractionState({
|
||||
locale,
|
||||
}: UseNoteListInteractionStateParams) {
|
||||
const changesContextMenu = useChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles, locale })
|
||||
const noteListContextMenu = useNoteListContextMenu({
|
||||
locale,
|
||||
onEnterNeighborhood,
|
||||
onOpenInNewWindow,
|
||||
onArchivePaths: onBulkArchive,
|
||||
onDeletePaths: onBulkDeletePermanently,
|
||||
})
|
||||
const {
|
||||
collapsedGroups,
|
||||
handleClickNote,
|
||||
@@ -382,6 +390,7 @@ function useNoteListInteractionState({
|
||||
handleCreateNote,
|
||||
handleListKeyDown,
|
||||
multiSelect,
|
||||
noteListContextMenu,
|
||||
noteListKeyboard,
|
||||
toggleGroup,
|
||||
}
|
||||
@@ -397,7 +406,8 @@ interface UseRenderItemParams {
|
||||
resolvedGetNoteStatus: (path: string) => NoteStatus
|
||||
getChangeStatus: (path: string) => ModifiedFile['status'] | undefined
|
||||
handleClickNote: (entry: VaultEntry, event: React.MouseEvent) => void
|
||||
noteContextMenu?: ((entry: VaultEntry, event: React.MouseEvent) => void) | undefined
|
||||
changesContextMenu?: ((entry: VaultEntry, event: React.MouseEvent) => void) | undefined
|
||||
noteListContextMenu?: ((entry: VaultEntry, event: React.MouseEvent) => void) | undefined
|
||||
multiSelect: MultiSelectState
|
||||
noteListKeyboard: { highlightedPath: string | null }
|
||||
}
|
||||
@@ -412,11 +422,12 @@ function useRenderItem({
|
||||
resolvedGetNoteStatus,
|
||||
getChangeStatus,
|
||||
handleClickNote,
|
||||
noteContextMenu,
|
||||
changesContextMenu,
|
||||
noteListContextMenu,
|
||||
multiSelect,
|
||||
noteListKeyboard,
|
||||
}: UseRenderItemParams) {
|
||||
const contextMenuHandler = isChangesView && onDiscardFile ? noteContextMenu : undefined
|
||||
const contextMenuHandler = isChangesView && onDiscardFile ? changesContextMenu : noteListContextMenu
|
||||
|
||||
return useCallback((entry: VaultEntry, options?: { forceSelected?: boolean }) => (
|
||||
isDeletedNoteEntry(entry) ? (
|
||||
@@ -581,7 +592,12 @@ function buildNoteListLayoutModel(params: {
|
||||
handleBulkArchive: params.interaction.handleBulkArchive,
|
||||
handleBulkDeletePermanently: params.interaction.handleBulkDeletePermanently,
|
||||
handleBulkUnarchive: params.interaction.handleBulkUnarchive,
|
||||
contextMenuNode: params.interaction.changesContextMenu.contextMenuNode,
|
||||
contextMenuNode: (
|
||||
<>
|
||||
{params.interaction.changesContextMenu.contextMenuNode}
|
||||
{params.interaction.noteListContextMenu.contextMenuNode}
|
||||
</>
|
||||
),
|
||||
dialogNode: params.interaction.changesContextMenu.dialogNode,
|
||||
}
|
||||
}
|
||||
@@ -680,7 +696,8 @@ export function useNoteListModel({
|
||||
resolvedGetNoteStatus,
|
||||
getChangeStatus: interaction.getChangeStatus,
|
||||
handleClickNote: interaction.handleClickNote,
|
||||
noteContextMenu: interaction.changesContextMenu.handleNoteContextMenu,
|
||||
changesContextMenu: interaction.changesContextMenu.handleNoteContextMenu,
|
||||
noteListContextMenu: interaction.noteListContextMenu.handleNoteContextMenu,
|
||||
multiSelect: interaction.multiSelect,
|
||||
noteListKeyboard: interaction.noteListKeyboard,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user