| author | |
| committer | |
| log | 3efabdcd3a7a6f0ed56443197a65a3a474cddb6c |
| tree | 8668cc68fb0692ac69450ed87c3e5a3512e8d26f |
| parent | c5f7cd40ba00c015373fdb95818916642917e000 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: ss <ss@barekey.dev>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>6 files changed, 215 insertions(+), 31 deletions(-)
apps/desktop/src/electron/ElectronMenu.ts+6| ... | ... | @@ -44,6 +44,12 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM |
| 44 | 44 | continue; |
| 45 | 45 | } |
| 46 | 46 | |
| 47 | // Header items are decorative section labels for the web fallback only — | |
| 48 | // Electron's native menu has no equivalent affordance, so we skip them. | |
| 49 | if (sourceItem.header === true) { | |
| 50 | continue; | |
| 51 | } | |
| 52 | ||
| 47 | 53 | const normalizedItem: ContextMenuItem = { |
| 48 | 54 | id: sourceItem.id, |
| 49 | 55 | label: sourceItem.label, |
apps/web/src/components/CommandPalette.logic.ts+2| ... | ... | @@ -95,6 +95,7 @@ export function buildProjectActionItems(input: { |
| 95 | 95 | valuePrefix: string; |
| 96 | 96 | icon: (project: Project) => ReactNode; |
| 97 | 97 | runProject: (project: Project) => Promise<void>; |
| 98 | shortcutCommand?: KeybindingCommand; | |
| 98 | 99 | }): CommandPaletteActionItem[] { |
| 99 | 100 | return input.projects.map((project) => ({ |
| 100 | 101 | kind: "action", |
| ... | ... | @@ -103,6 +104,7 @@ export function buildProjectActionItems(input: { |
| 103 | 104 | title: project.name, |
| 104 | 105 | description: project.cwd, |
| 105 | 106 | icon: input.icon(project), |
| 107 | ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), | |
| 106 | 108 | run: async () => { |
| 107 | 109 | await input.runProject(project); |
| 108 | 110 | }, |
apps/web/src/components/CommandPalette.tsx+1| ... | ... | @@ -647,6 +647,7 @@ function OpenCommandPaletteDialog() { |
| 647 | 647 | buildProjectActionItems({ |
| 648 | 648 | projects, |
| 649 | 649 | valuePrefix: "new-thread-in", |
| 650 | shortcutCommand: "chat.new", | |
| 650 | 651 | icon: (project) => ( |
| 651 | 652 | <ProjectFavicon |
| 652 | 653 | environmentId={project.environmentId} |
apps/web/src/components/Sidebar.tsx+20-16| ... | ... | @@ -217,6 +217,8 @@ const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = |
| 217 | 217 | repository_path: "Group by repository path", |
| 218 | 218 | separate: "Keep separate", |
| 219 | 219 | }; |
| 220 | const SIDEBAR_ICON_ACTION_BUTTON_CLASS = | |
| 221 | "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; | |
| 220 | 222 | |
| 221 | 223 | function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { |
| 222 | 224 | return Math.min( |
| ... | ... | @@ -635,7 +637,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 635 | 637 | data-thread-selection-safe |
| 636 | 638 | data-testid={`thread-archive-confirm-${thread.id}`} |
| 637 | 639 | aria-label={`Confirm archive ${thread.title}`} |
| 638 | className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-full bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40" | |
| 640 | className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-md bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40" | |
| 639 | 641 | onPointerDown={stopPropagationOnPointerDown} |
| 640 | 642 | onClick={handleConfirmArchiveClick} |
| 641 | 643 | > |
| ... | ... | @@ -643,13 +645,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 643 | 645 | </button> |
| 644 | 646 | ) : !isThreadRunning ? ( |
| 645 | 647 | appSettingsConfirmThreadArchive ? ( |
| 646 | <div className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100"> | |
| 648 | <div className="pointer-events-none absolute top-1/2 right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100"> | |
| 647 | 649 | <button |
| 648 | 650 | type="button" |
| 649 | 651 | data-thread-selection-safe |
| 650 | 652 | data-testid={`thread-archive-${thread.id}`} |
| 651 | 653 | aria-label={`Archive ${thread.title}`} |
| 652 | className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground/60 transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring" | |
| 654 | className={SIDEBAR_ICON_ACTION_BUTTON_CLASS} | |
| 653 | 655 | onPointerDown={stopPropagationOnPointerDown} |
| 654 | 656 | onClick={handleStartArchiveConfirmation} |
| 655 | 657 | > |
| ... | ... | @@ -660,13 +662,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 660 | 662 | <Tooltip> |
| 661 | 663 | <TooltipTrigger |
| 662 | 664 | render={ |
| 663 | <div className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100"> | |
| 665 | <div className="pointer-events-none absolute top-1/2 right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100"> | |
| 664 | 666 | <button |
| 665 | 667 | type="button" |
| 666 | 668 | data-thread-selection-safe |
| 667 | 669 | data-testid={`thread-archive-${thread.id}`} |
| 668 | 670 | aria-label={`Archive ${thread.title}`} |
| 669 | className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground/60 transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring" | |
| 671 | className={SIDEBAR_ICON_ACTION_BUTTON_CLASS} | |
| 670 | 672 | onPointerDown={stopPropagationOnPointerDown} |
| 671 | 673 | onClick={handleArchiveImmediateClick} |
| 672 | 674 | > |
| ... | ... | @@ -712,7 +714,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 712 | 714 | </Tooltip> |
| 713 | 715 | ) : ( |
| 714 | 716 | <span |
| 715 | className={`text-[10px] ${ | |
| 717 | className={`text-[10px] tabular-nums ${ | |
| 716 | 718 | isHighlighted |
| 717 | 719 | ? "text-foreground/72 dark:text-foreground/82" |
| 718 | 720 | : "text-muted-foreground/40" |
| ... | ... | @@ -1495,12 +1497,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1495 | 1497 | ...(options?.isDisabled?.(singleMember) ? { disabled: true } : {}), |
| 1496 | 1498 | }), |
| 1497 | 1499 | label, |
| 1500 | ...(action === "delete" ? { icon: "trash" } : {}), | |
| 1498 | 1501 | }; |
| 1499 | 1502 | } |
| 1500 | 1503 | |
| 1501 | 1504 | return { |
| 1502 | 1505 | id: `${action}:submenu`, |
| 1503 | 1506 | label, |
| 1507 | ...(action === "delete" ? { icon: "trash" } : {}), | |
| 1504 | 1508 | children: project.memberProjects.map((member) => |
| 1505 | 1509 | makeLeaf(action, member, { |
| 1506 | 1510 | ...(options?.destructive ? { destructive: true } : {}), |
| ... | ... | @@ -1512,10 +1516,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1512 | 1516 | |
| 1513 | 1517 | const clicked = await api.contextMenu.show( |
| 1514 | 1518 | [ |
| 1515 | buildTargetedItem("rename", "Rename project"), | |
| 1516 | buildTargetedItem("grouping", "Project grouping…"), | |
| 1517 | buildTargetedItem("copy-path", "Copy Project Path"), | |
| 1518 | buildTargetedItem("delete", "Remove project", { | |
| 1519 | buildTargetedItem("rename", "Rename"), | |
| 1520 | buildTargetedItem("grouping", "Group into..."), | |
| 1521 | buildTargetedItem("copy-path", "Copy Path"), | |
| 1522 | buildTargetedItem("delete", "Remove", { | |
| 1519 | 1523 | destructive: true, |
| 1520 | 1524 | }), |
| 1521 | 1525 | ], |
| ... | ... | @@ -1931,7 +1935,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1931 | 1935 | { id: "mark-unread", label: "Mark unread" }, |
| 1932 | 1936 | { id: "copy-path", label: "Copy Path" }, |
| 1933 | 1937 | { id: "copy-thread-id", label: "Copy Thread ID" }, |
| 1934 | { id: "delete", label: "Delete", destructive: true }, | |
| 1938 | { id: "delete", label: "Delete", destructive: true, icon: "trash" }, | |
| 1935 | 1939 | ], |
| 1936 | 1940 | position, |
| 1937 | 1941 | ); |
| ... | ... | @@ -2073,12 +2077,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2073 | 2077 | <Tooltip> |
| 2074 | 2078 | <TooltipTrigger |
| 2075 | 2079 | render={ |
| 2076 | <div className="pointer-events-none absolute top-1 right-1.5 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100"> | |
| 2080 | <div className="pointer-events-none absolute top-[calc(50%+1px)] right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100"> | |
| 2077 | 2081 | <button |
| 2078 | 2082 | type="button" |
| 2079 | 2083 | aria-label={`Create new thread in ${project.displayName}`} |
| 2080 | 2084 | data-testid="new-thread-button" |
| 2081 | className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 hover:bg-secondary hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring" | |
| 2085 | className={SIDEBAR_ICON_ACTION_BUTTON_CLASS} | |
| 2082 | 2086 | onClick={handleCreateThreadClick} |
| 2083 | 2087 | > |
| 2084 | 2088 | <SquarePenIcon className="size-3.5" /> |
| ... | ... | @@ -2316,7 +2320,7 @@ function ProjectSortMenu({ |
| 2316 | 2320 | <Tooltip> |
| 2317 | 2321 | <TooltipTrigger |
| 2318 | 2322 | render={ |
| 2319 | <MenuTrigger className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" /> | |
| 2323 | <MenuTrigger className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" /> | |
| 2320 | 2324 | } |
| 2321 | 2325 | > |
| 2322 | 2326 | <ArrowUpDownIcon className="size-3.5" /> |
| ... | ... | @@ -2654,7 +2658,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( |
| 2654 | 2658 | /> |
| 2655 | 2659 | } |
| 2656 | 2660 | > |
| 2657 | <SearchIcon className="size-3.5" /> | |
| 2661 | <SearchIcon className="size-3.5 text-muted-foreground/70" /> | |
| 2658 | 2662 | <span className="flex-1 truncate text-left text-xs">Search</span> |
| 2659 | 2663 | {commandPaletteShortcutLabel ? ( |
| 2660 | 2664 | <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]"> |
| ... | ... | @@ -2711,7 +2715,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( |
| 2711 | 2715 | type="button" |
| 2712 | 2716 | aria-label="Add project" |
| 2713 | 2717 | data-testid="sidebar-add-project-trigger" |
| 2714 | className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" | |
| 2718 | className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" | |
| 2715 | 2719 | onClick={openAddProject} |
| 2716 | 2720 | /> |
| 2717 | 2721 | } |
apps/web/src/contextMenuFallback.ts+178-15| ... | ... | @@ -1,5 +1,74 @@ |
| 1 | 1 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | const SVG_NS = "http://www.w3.org/2000/svg"; | |
| 4 | ||
| 5 | // Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2). | |
| 6 | const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<string, string> }>> = { | |
| 7 | pencil: [ | |
| 8 | { | |
| 9 | tag: "path", | |
| 10 | attrs: { | |
| 11 | d: "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z", | |
| 12 | }, | |
| 13 | }, | |
| 14 | { tag: "path", attrs: { d: "m15 5 4 4" } }, | |
| 15 | ], | |
| 16 | copy: [ | |
| 17 | { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } }, | |
| 18 | { tag: "path", attrs: { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" } }, | |
| 19 | ], | |
| 20 | "folder-tree": [ | |
| 21 | { | |
| 22 | tag: "path", | |
| 23 | attrs: { | |
| 24 | d: "M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z", | |
| 25 | }, | |
| 26 | }, | |
| 27 | { | |
| 28 | tag: "path", | |
| 29 | attrs: { | |
| 30 | d: "M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z", | |
| 31 | }, | |
| 32 | }, | |
| 33 | { tag: "path", attrs: { d: "M3 5a2 2 0 0 0 2 2h3" } }, | |
| 34 | { tag: "path", attrs: { d: "M3 3v13a2 2 0 0 0 2 2h3" } }, | |
| 35 | ], | |
| 36 | trash: [ | |
| 37 | { tag: "path", attrs: { d: "M3 6h18" } }, | |
| 38 | { tag: "path", attrs: { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" } }, | |
| 39 | { tag: "path", attrs: { d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" } }, | |
| 40 | { tag: "line", attrs: { x1: "10", x2: "10", y1: "11", y2: "17" } }, | |
| 41 | { tag: "line", attrs: { x1: "14", x2: "14", y1: "11", y2: "17" } }, | |
| 42 | ], | |
| 43 | }; | |
| 44 | ||
| 45 | function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null { | |
| 46 | const paths = ICON_PATHS[name]; | |
| 47 | if (!paths) { | |
| 48 | return null; | |
| 49 | } | |
| 50 | const svg = document.createElementNS(SVG_NS, "svg"); | |
| 51 | svg.setAttribute("xmlns", SVG_NS); | |
| 52 | svg.setAttribute("viewBox", "0 0 24 24"); | |
| 53 | svg.setAttribute("fill", "none"); | |
| 54 | svg.setAttribute("stroke", "currentColor"); | |
| 55 | svg.setAttribute("stroke-width", "2"); | |
| 56 | svg.setAttribute("stroke-linecap", "round"); | |
| 57 | svg.setAttribute("stroke-linejoin", "round"); | |
| 58 | svg.setAttribute( | |
| 59 | "class", | |
| 60 | tone === "destructive" ? "size-3.5 shrink-0" : "size-3.5 shrink-0 text-muted-foreground", | |
| 61 | ); | |
| 62 | for (const node of paths) { | |
| 63 | const child = document.createElementNS(SVG_NS, node.tag); | |
| 64 | for (const [key, value] of Object.entries(node.attrs)) { | |
| 65 | child.setAttribute(key, value); | |
| 66 | } | |
| 67 | svg.appendChild(child); | |
| 68 | } | |
| 69 | return svg; | |
| 70 | } | |
| 71 | ||
| 3 | 72 | function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferredTop: number) { |
| 4 | 73 | const rect = menu.getBoundingClientRect(); |
| 5 | 74 | const left = Math.min( |
| ... | ... | @@ -14,6 +83,24 @@ function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferre |
| 14 | 83 | menu.style.top = `${top}px`; |
| 15 | 84 | } |
| 16 | 85 | |
| 86 | function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly HTMLDivElement[]) { | |
| 87 | if (typeof Node !== "undefined" && target instanceof Node) { | |
| 88 | return menuStack.some((menu) => menu.contains(target)); | |
| 89 | } | |
| 90 | if (!target || typeof target !== "object") { | |
| 91 | return false; | |
| 92 | } | |
| 93 | ||
| 94 | let current: unknown = target; | |
| 95 | while (current && typeof current === "object") { | |
| 96 | if (menuStack.includes(current as HTMLDivElement)) { | |
| 97 | return true; | |
| 98 | } | |
| 99 | current = (current as { parent?: unknown }).parent; | |
| 100 | } | |
| 101 | return false; | |
| 102 | } | |
| 103 | ||
| 17 | 104 | /** |
| 18 | 105 | * Imperative DOM-based context menu for non-Electron environments. |
| 19 | 106 | * Supports nested submenus and resolves with the clicked leaf item id. |
| ... | ... | @@ -23,14 +110,18 @@ export function showContextMenuFallback<T extends string>( |
| 23 | 110 | position?: { x: number; y: number }, |
| 24 | 111 | ): Promise<T | null> { |
| 25 | 112 | return new Promise<T | null>((resolve) => { |
| 26 | const overlay = document.createElement("div"); | |
| 27 | overlay.style.cssText = "position:fixed;inset:0;z-index:9999"; | |
| 28 | ||
| 29 | 113 | const menuStack: HTMLDivElement[] = []; |
| 114 | let isDisposed = false; | |
| 115 | let canDismissFromPointer = false; | |
| 30 | 116 | |
| 31 | 117 | const cleanup = (result: T | null) => { |
| 118 | if (isDisposed) { | |
| 119 | return; | |
| 120 | } | |
| 121 | isDisposed = true; | |
| 32 | 122 | document.removeEventListener("keydown", onKeyDown); |
| 33 | overlay.remove(); | |
| 123 | document.removeEventListener("pointerdown", onPointerDown, true); | |
| 124 | document.removeEventListener("contextmenu", onContextMenu, true); | |
| 34 | 125 | for (const menu of menuStack) { |
| 35 | 126 | menu.remove(); |
| 36 | 127 | } |
| ... | ... | @@ -44,6 +135,21 @@ export function showContextMenuFallback<T extends string>( |
| 44 | 135 | } |
| 45 | 136 | }; |
| 46 | 137 | |
| 138 | const onPointerDown = (event: PointerEvent) => { | |
| 139 | if (!canDismissFromPointer || isNodeWithinMenuStack(event.target, menuStack)) { | |
| 140 | return; | |
| 141 | } | |
| 142 | cleanup(null); | |
| 143 | }; | |
| 144 | ||
| 145 | const onContextMenu = (event: MouseEvent) => { | |
| 146 | if (!canDismissFromPointer || isNodeWithinMenuStack(event.target, menuStack)) { | |
| 147 | return; | |
| 148 | } | |
| 149 | event.preventDefault(); | |
| 150 | cleanup(null); | |
| 151 | }; | |
| 152 | ||
| 47 | 153 | const closeMenusFromLevel = (level: number) => { |
| 48 | 154 | while (menuStack.length > level) { |
| 49 | 155 | menuStack.pop()?.remove(); |
| ... | ... | @@ -60,24 +166,60 @@ export function showContextMenuFallback<T extends string>( |
| 60 | 166 | |
| 61 | 167 | const menu = document.createElement("div"); |
| 62 | 168 | menu.className = |
| 63 | "fixed z-[10000] min-w-[160px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; | |
| 169 | "fixed z-[10000] min-w-32 max-w-sm overflow-hidden rounded-lg border border-border bg-popover bg-clip-padding text-popover-foreground shadow-lg/5 outline-none"; | |
| 170 | menu.style.cssText = | |
| 171 | "position:fixed;z-index:10000;min-width:8rem;max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);border:1px solid var(--border);background:var(--popover);background-clip:padding-box;color:var(--popover-foreground);box-shadow:0 10px 15px -3px rgb(0 0 0 / 0.05),0 4px 6px -4px rgb(0 0 0 / 0.05);outline:none;pointer-events:auto;"; | |
| 64 | 172 | menu.style.left = `${preferredLeft}px`; |
| 65 | 173 | menu.style.top = `${preferredTop}px`; |
| 66 | 174 | menu.dataset.level = String(level); |
| 67 | 175 | |
| 176 | const inner = document.createElement("div"); | |
| 177 | inner.className = | |
| 178 | "max-h-[min(24rem,70vh)] min-w-0 max-w-sm overflow-y-auto overflow-x-hidden p-1"; | |
| 179 | inner.style.cssText = | |
| 180 | "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;"; | |
| 181 | ||
| 68 | 182 | for (const item of entries) { |
| 69 | const button = document.createElement("button"); | |
| 70 | button.type = "button"; | |
| 183 | if (item.header === true) { | |
| 184 | const header = document.createElement("div"); | |
| 185 | header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs"; | |
| 186 | header.textContent = item.label; | |
| 187 | inner.appendChild(header); | |
| 188 | continue; | |
| 189 | } | |
| 190 | ||
| 71 | 191 | const hasChildren = Array.isArray(item.children) && item.children.length > 0; |
| 72 | 192 | const isLeafDestructive = |
| 73 | 193 | !hasChildren && (item.destructive === true || item.id === ("delete" as T)); |
| 194 | ||
| 195 | const button = document.createElement("button"); | |
| 196 | button.type = "button"; | |
| 74 | 197 | const isDisabled = item.disabled === true; |
| 75 | 198 | button.disabled = isDisabled; |
| 199 | const rowBase = | |
| 200 | "flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left outline-none transition-colors sm:min-h-7 sm:text-sm min-h-8 text-base"; | |
| 76 | 201 | button.className = isDisabled |
| 77 | ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed" | |
| 202 | ? `${rowBase} pointer-events-none cursor-not-allowed text-muted-foreground opacity-64` | |
| 78 | 203 | : isLeafDestructive |
| 79 | ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" | |
| 80 | : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; | |
| 204 | ? `${rowBase} text-destructive-foreground hover:bg-destructive/10 hover:text-destructive-foreground` | |
| 205 | : `${rowBase} text-foreground hover:bg-accent hover:text-accent-foreground`; | |
| 206 | button.style.cssText = | |
| 207 | "display:flex;width:100%;min-height:1.75rem;align-items:center;gap:0.5rem;border:0;border-radius:var(--radius-sm);background:transparent;padding:0.25rem 0.5rem;color:var(--foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.875rem;line-height:1.25rem;text-align:left;cursor:default;"; | |
| 208 | if (isLeafDestructive) { | |
| 209 | button.style.color = "var(--destructive-foreground)"; | |
| 210 | } | |
| 211 | if (isDisabled) { | |
| 212 | button.style.color = "var(--muted-foreground)"; | |
| 213 | button.style.opacity = "0.64"; | |
| 214 | button.style.pointerEvents = "none"; | |
| 215 | } | |
| 216 | ||
| 217 | if (typeof item.icon === "string") { | |
| 218 | const icon = createIconElement(item.icon, isLeafDestructive ? "destructive" : "neutral"); | |
| 219 | if (icon) { | |
| 220 | button.appendChild(icon); | |
| 221 | } | |
| 222 | } | |
| 81 | 223 | |
| 82 | 224 | const label = document.createElement("span"); |
| 83 | 225 | label.className = "min-w-0 flex-1 truncate"; |
| ... | ... | @@ -86,12 +228,27 @@ export function showContextMenuFallback<T extends string>( |
| 86 | 228 | |
| 87 | 229 | if (hasChildren) { |
| 88 | 230 | const chevron = document.createElement("span"); |
| 89 | chevron.className = "shrink-0 text-muted-foreground/70"; | |
| 90 | chevron.textContent = "›"; | |
| 231 | chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none"; | |
| 232 | chevron.textContent = ">"; | |
| 91 | 233 | button.appendChild(chevron); |
| 92 | 234 | } |
| 93 | 235 | |
| 94 | 236 | if (!isDisabled) { |
| 237 | button.addEventListener("mouseenter", () => { | |
| 238 | button.style.background = isLeafDestructive | |
| 239 | ? "color-mix(in srgb, var(--destructive) 10%, transparent)" | |
| 240 | : "var(--accent)"; | |
| 241 | button.style.color = isLeafDestructive | |
| 242 | ? "var(--destructive-foreground)" | |
| 243 | : "var(--accent-foreground)"; | |
| 244 | }); | |
| 245 | button.addEventListener("mouseleave", () => { | |
| 246 | button.style.background = "transparent"; | |
| 247 | button.style.color = isLeafDestructive | |
| 248 | ? "var(--destructive-foreground)" | |
| 249 | : "var(--foreground)"; | |
| 250 | }); | |
| 251 | ||
| 95 | 252 | if (hasChildren) { |
| 96 | 253 | button.addEventListener("mouseenter", () => { |
| 97 | 254 | const rect = button.getBoundingClientRect(); |
| ... | ... | @@ -119,9 +276,11 @@ export function showContextMenuFallback<T extends string>( |
| 119 | 276 | } |
| 120 | 277 | } |
| 121 | 278 | |
| 122 | menu.appendChild(button); | |
| 279 | inner.appendChild(button); | |
| 123 | 280 | } |
| 124 | 281 | |
| 282 | menu.appendChild(inner); | |
| 283 | ||
| 125 | 284 | menu.addEventListener("mouseenter", () => { |
| 126 | 285 | closeMenusFromLevel(level + 1); |
| 127 | 286 | }); |
| ... | ... | @@ -134,9 +293,13 @@ export function showContextMenuFallback<T extends string>( |
| 134 | 293 | }); |
| 135 | 294 | }; |
| 136 | 295 | |
| 137 | overlay.addEventListener("mousedown", () => cleanup(null)); | |
| 138 | 296 | document.addEventListener("keydown", onKeyDown); |
| 139 | document.body.appendChild(overlay); | |
| 297 | document.addEventListener("pointerdown", onPointerDown, true); | |
| 298 | document.addEventListener("contextmenu", onContextMenu, true); | |
| 140 | 299 | openMenu(items, position?.x ?? 0, position?.y ?? 0, 0); |
| 300 | ||
| 301 | requestAnimationFrame(() => { | |
| 302 | canDismissFromPointer = true; | |
| 303 | }); | |
| 141 | 304 | }); |
| 142 | 305 | } |
packages/contracts/src/ipc.ts+8| ... | ... | @@ -86,6 +86,10 @@ export interface ContextMenuItem<T extends string = string> { |
| 86 | 86 | label: string; |
| 87 | 87 | destructive?: boolean; |
| 88 | 88 | disabled?: boolean; |
| 89 | /** Renders as a non-interactive section header label. Web fallback only — stripped on desktop native menus. */ | |
| 90 | header?: boolean; | |
| 91 | /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */ | |
| 92 | icon?: string; | |
| 89 | 93 | children?: readonly ContextMenuItem<T>[]; |
| 90 | 94 | } |
| 91 | 95 | |
| ... | ... | @@ -94,6 +98,8 @@ export interface ContextMenuItemSchemaType { |
| 94 | 98 | readonly label: string; |
| 95 | 99 | readonly destructive?: boolean; |
| 96 | 100 | readonly disabled?: boolean; |
| 101 | readonly header?: boolean; | |
| 102 | readonly icon?: string; | |
| 97 | 103 | readonly children?: readonly ContextMenuItemSchemaType[]; |
| 98 | 104 | } |
| 99 | 105 | |
| ... | ... | @@ -102,6 +108,8 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc |
| 102 | 108 | label: Schema.String, |
| 103 | 109 | destructive: Schema.optionalKey(Schema.Boolean), |
| 104 | 110 | disabled: Schema.optionalKey(Schema.Boolean), |
| 111 | header: Schema.optionalKey(Schema.Boolean), | |
| 112 | icon: Schema.optionalKey(Schema.String), | |
| 105 | 113 | children: Schema.optionalKey( |
| 106 | 114 | Schema.Array( |
| 107 | 115 | Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema), |