Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim

This commit is contained in:
github-actions[bot]
2026-05-20 18:02:43 +00:00
committed by GitHub
4 changed files with 245 additions and 5 deletions

View 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])
})
})

View 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,
}
}

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

View File

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