From 23bf7d79076f7732d4524d449335f8b612034afb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 21:19:55 +0000 Subject: [PATCH 1/4] Initial plan From af81250f4557d9f03269b56ab05a2c5aad1b4ce2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 21:32:54 +0000 Subject: [PATCH 2/4] Add preview context menu renderer Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/preview/mock/mockwaveenv.test.ts | 22 ++ frontend/preview/mock/mockwaveenv.ts | 6 +- frontend/preview/preview-contextmenu.tsx | 315 ++++++++++++++++++++++ frontend/preview/preview.tsx | 6 +- 4 files changed, 344 insertions(+), 5 deletions(-) create mode 100644 frontend/preview/mock/mockwaveenv.test.ts create mode 100644 frontend/preview/preview-contextmenu.tsx diff --git a/frontend/preview/mock/mockwaveenv.test.ts b/frontend/preview/mock/mockwaveenv.test.ts new file mode 100644 index 0000000000..953e8412d4 --- /dev/null +++ b/frontend/preview/mock/mockwaveenv.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "vitest"; + +const { showPreviewContextMenu } = vi.hoisted(() => ({ + showPreviewContextMenu: vi.fn(), +})); + +vi.mock("../preview-contextmenu", () => ({ + showPreviewContextMenu, +})); + +describe("makeMockWaveEnv", () => { + it("uses the preview context menu by default", async () => { + const { makeMockWaveEnv } = await import("./mockwaveenv"); + const env = makeMockWaveEnv(); + const menu = [{ label: "Open" }]; + const event = { stopPropagation: vi.fn() } as any; + + env.showContextMenu(menu, event); + + expect(showPreviewContextMenu).toHaveBeenCalledWith(menu, event); + }); +}); diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index fdbeb60e4a..edf2fe4771 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -10,6 +10,7 @@ import { WaveEnv } from "@/app/waveenv/waveenv"; import { PlatformMacOS, PlatformWindows } from "@/util/platformutil"; import { Atom, atom, PrimitiveAtom, useAtomValue } from "jotai"; import { DefaultFullConfig } from "./defaultconfig"; +import { showPreviewContextMenu } from "../preview-contextmenu"; import { previewElectronApi } from "./preview-electron-api"; // What works "out of the box" in the mock environment (no MockEnv overrides needed): @@ -308,10 +309,7 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { return Promise.resolve(crypto.randomUUID()); }), showContextMenu: - overrides.showContextMenu ?? - ((menu, e) => { - console.log("[mock showContextMenu]", menu, e); - }), + overrides.showContextMenu ?? showPreviewContextMenu, getLocalHostDisplayNameAtom: () => { return localHostDisplayNameAtom; }, diff --git a/frontend/preview/preview-contextmenu.tsx b/frontend/preview/preview-contextmenu.tsx new file mode 100644 index 0000000000..62dab3295a --- /dev/null +++ b/frontend/preview/preview-contextmenu.tsx @@ -0,0 +1,315 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { + autoUpdate, + flip, + FloatingPortal, + offset, + shift, + type Placement, + type VirtualElement, + useFloating, +} from "@floating-ui/react"; +import { cn } from "@/util/util"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; + +type PreviewContextMenuState = { + items: ContextMenuItem[]; + x: number; + y: number; +}; + +type PreviewContextMenuPanelProps = { + items: ContextMenuItem[]; + point?: { x: number; y: number }; + referenceElement?: HTMLElement; + placement: Placement; + depth: number; + parentPath: number[]; + openPath: number[]; + setOpenPath: (path: number[]) => void; + closeMenu: () => void; +}; + +type PreviewContextMenuItemProps = { + item: ContextMenuItem; + itemPath: number[]; + depth: number; + parentPath: number[]; + openPath: number[]; + setOpenPath: (path: number[]) => void; + closeMenu: () => void; +}; + +let previewContextMenuListener: ((state: PreviewContextMenuState) => void) | null = null; + +function makeVirtualElement(x: number, y: number): VirtualElement { + return { + getBoundingClientRect() { + return { + x, + y, + width: 0, + height: 0, + top: y, + right: x, + bottom: y, + left: x, + toJSON: () => undefined, + } as DOMRect; + }, + }; +} + +function isPathOpen(openPath: number[], path: number[]): boolean { + if (path.length > openPath.length) { + return false; + } + return path.every((segment, index) => openPath[index] === segment); +} + +function getVisibleItems(items: ContextMenuItem[]): ContextMenuItem[] { + return items.filter((item) => item.visible !== false); +} + +function activateItem(item: ContextMenuItem, closeMenu: () => void): void { + closeMenu(); + item.click?.(); +} + +const PreviewContextMenuItem = memo( + ({ item, itemPath, depth, parentPath, openPath, setOpenPath, closeMenu }: PreviewContextMenuItemProps) => { + const rowRef = useRef(null); + const submenuItems = getVisibleItems(item.submenu ?? []); + const hasSubmenu = submenuItems.length > 0; + const isDisabled = item.enabled === false; + const isHeader = item.type === "header"; + const isSeparator = item.type === "separator"; + const isChecked = item.type === "checkbox" || item.type === "radio" ? item.checked === true : false; + const isSubmenuOpen = hasSubmenu && isPathOpen(openPath, itemPath); + + if (isSeparator) { + return
; + } + + const handleMouseEnter = () => { + if (hasSubmenu) { + setOpenPath(itemPath); + return; + } + setOpenPath(parentPath); + }; + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (isDisabled || isHeader) { + return; + } + if (hasSubmenu) { + setOpenPath(itemPath); + return; + } + activateItem(item, closeMenu); + }; + + return ( + <> +
+ {isHeader ? ( + {item.label} + ) : ( + <> + + {isChecked ? : null} + +
+ {item.label} + {item.sublabel ? {item.sublabel} : null} +
+ {hasSubmenu ? ( + + + + ) : null} + + )} +
+ {hasSubmenu && isSubmenuOpen && rowRef.current != null ? ( + + ) : null} + + ); + } +); + +PreviewContextMenuItem.displayName = "PreviewContextMenuItem"; + +const PreviewContextMenuPanel = memo( + ({ items, point, referenceElement, placement, depth, parentPath, openPath, setOpenPath, closeMenu }: PreviewContextMenuPanelProps) => { + const visibleItems = getVisibleItems(items); + const virtualReference = useMemo(() => { + if (point == null) { + return null; + } + return makeVirtualElement(point.x, point.y); + }, [point]); + const { refs, floatingStyles } = useFloating({ + open: true, + placement, + strategy: "fixed", + whileElementsMounted: autoUpdate, + middleware: [ + offset(depth === 0 ? 4 : { mainAxis: -4, crossAxis: -4 }), + flip({ padding: 8 }), + shift({ padding: 8 }), + ], + }); + + useEffect(() => { + if (referenceElement != null) { + refs.setReference(referenceElement); + return; + } + refs.setPositionReference(virtualReference); + }, [referenceElement, refs, virtualReference]); + + if (visibleItems.length === 0) { + return null; + } + + return ( +
+ {visibleItems.map((item, index) => ( + + ))} +
+ ); + } +); + +PreviewContextMenuPanel.displayName = "PreviewContextMenuPanel"; + +export function showPreviewContextMenu(menu: ContextMenuItem[], e: React.MouseEvent): void { + e.stopPropagation(); + e.preventDefault?.(); + previewContextMenuListener?.({ + items: menu, + x: e.clientX ?? 0, + y: e.clientY ?? 0, + }); +} + +export const PreviewContextMenu = memo(() => { + const [menuState, setMenuState] = useState(null); + const [openPath, setOpenPath] = useState([]); + const portalRef = useRef(null); + + const closeMenu = () => { + setMenuState(null); + setOpenPath([]); + }; + + useEffect(() => { + previewContextMenuListener = (state) => { + setMenuState(state); + setOpenPath([]); + }; + return () => { + previewContextMenuListener = null; + }; + }, []); + + useEffect(() => { + if (menuState == null) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + if (portalRef.current?.contains(event.target as Node)) { + return; + } + closeMenu(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closeMenu(); + } + }; + + document.addEventListener("pointerdown", handlePointerDown, true); + document.addEventListener("keydown", handleKeyDown); + window.addEventListener("blur", closeMenu); + window.addEventListener("resize", closeMenu); + window.addEventListener("scroll", closeMenu, true); + return () => { + document.removeEventListener("pointerdown", handlePointerDown, true); + document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("blur", closeMenu); + window.removeEventListener("resize", closeMenu); + window.removeEventListener("scroll", closeMenu, true); + }; + }, [menuState]); + + if (menuState == null) { + return null; + } + + return ( + +
+ +
+
+ ); +}); + +PreviewContextMenu.displayName = "PreviewContextMenu"; diff --git a/frontend/preview/preview.tsx b/frontend/preview/preview.tsx index 303c9ab443..32c09f058e 100644 --- a/frontend/preview/preview.tsx +++ b/frontend/preview/preview.tsx @@ -13,6 +13,7 @@ import React, { lazy, Suspense, useRef } from "react"; import { createRoot } from "react-dom/client"; import { makeMockWaveEnv } from "./mock/mockwaveenv"; import { installPreviewElectronApi } from "./mock/preview-electron-api"; +import { PreviewContextMenu } from "./preview-contextmenu"; import "overlayscrollbars/overlayscrollbars.css"; import "../app/app.scss"; @@ -104,7 +105,10 @@ function PreviewRoot() { return ( - + <> + + + ); From c66521d256567594738b978cd6aeb73089460f57 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 21:41:30 +0000 Subject: [PATCH 3/4] Finalize preview context menu support Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/preview/preview-contextmenu.tsx | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/frontend/preview/preview-contextmenu.tsx b/frontend/preview/preview-contextmenu.tsx index 62dab3295a..5594a10c7a 100644 --- a/frontend/preview/preview-contextmenu.tsx +++ b/frontend/preview/preview-contextmenu.tsx @@ -43,6 +43,7 @@ type PreviewContextMenuItemProps = { }; let previewContextMenuListener: ((state: PreviewContextMenuState) => void) | null = null; +const previewContextMenuItemIds = new WeakMap(); function makeVirtualElement(x: number, y: number): VirtualElement { return { @@ -56,7 +57,7 @@ function makeVirtualElement(x: number, y: number): VirtualElement { right: x, bottom: y, left: x, - toJSON: () => undefined, + toJSON: () => ({}), } as DOMRect; }, }; @@ -78,6 +79,16 @@ function activateItem(item: ContextMenuItem, closeMenu: () => void): void { item.click?.(); } +function getPreviewContextMenuItemId(item: ContextMenuItem): string { + const existingId = previewContextMenuItemIds.get(item); + if (existingId != null) { + return existingId; + } + const newId = crypto.randomUUID(); + previewContextMenuItemIds.set(item, newId); + return newId; +} + const PreviewContextMenuItem = memo( ({ item, itemPath, depth, parentPath, openPath, setOpenPath, closeMenu }: PreviewContextMenuItemProps) => { const rowRef = useRef(null); @@ -212,7 +223,7 @@ const PreviewContextMenuPanel = memo( > {visibleItems.map((item, index) => ( { - const [menuState, setMenuState] = useState(null); + const [menuState, setMenuState] = useState(null); const [openPath, setOpenPath] = useState([]); const portalRef = useRef(null); From 3fec303c2db6d25c44d4f2709a2a8d57ef309cc9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:15:21 +0000 Subject: [PATCH 4/4] Fix preview submenu clipping Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/preview/preview-contextmenu.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/preview/preview-contextmenu.tsx b/frontend/preview/preview-contextmenu.tsx index 5594a10c7a..0be376c6bb 100644 --- a/frontend/preview/preview-contextmenu.tsx +++ b/frontend/preview/preview-contextmenu.tsx @@ -101,7 +101,7 @@ const PreviewContextMenuItem = memo( const isSubmenuOpen = hasSubmenu && isPathOpen(openPath, itemPath); if (isSeparator) { - return
; + return
; } const handleMouseEnter = () => { @@ -133,9 +133,9 @@ const PreviewContextMenuItem = memo( aria-checked={item.type === "checkbox" || item.type === "radio" ? isChecked : undefined} data-context-menu-item={item.label ?? item.type ?? "item"} className={cn( - "flex min-h-8 items-center gap-3 px-3 text-sm text-foreground select-none", + "flex min-h-7 items-center gap-2 px-2.5 text-xs text-foreground select-none", !isHeader && "cursor-pointer", - isHeader && "px-3 py-1 text-xxs uppercase tracking-[0.08em] text-muted", + isHeader && "px-2.5 py-0.5 text-[10px] uppercase tracking-[0.08em] text-muted", !isHeader && !isDisabled && "hover:bg-hoverbg", isDisabled && "text-muted", isSubmenuOpen && "bg-hoverbg" @@ -147,15 +147,15 @@ const PreviewContextMenuItem = memo( {item.label} ) : ( <> - + {isChecked ? : null}
{item.label} - {item.sublabel ? {item.sublabel} : null} + {item.sublabel ? {item.sublabel} : null}
{hasSubmenu ? ( - + ) : null} @@ -218,7 +218,7 @@ const PreviewContextMenuPanel = memo(
{visibleItems.map((item, index) => (