diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 460684929..449c14a64 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -10,7 +10,6 @@ import { dialog, ipcMain, Menu, - nativeImage, nativeTheme, protocol, shell, @@ -24,7 +23,6 @@ import type { } from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; -import type { ContextMenuItem } from "@t3tools/contracts"; import { NetService } from "@t3tools/shared/Net"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { showDesktopConfirmDialog } from "./confirmDialog"; @@ -49,7 +47,6 @@ fixPath(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; -const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; @@ -92,7 +89,6 @@ let desktopLogSink: RotatingFileSink | null = null; let backendLogSink: RotatingFileSink | null = null; let restoreStdIoCapture: (() => void) | null = null; -let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ platform: process.platform, processArch: process.arch, @@ -248,29 +244,6 @@ function captureBackendOutput(child: ChildProcess.ChildProcess): void { } initializePackagedLogging(); - -function getDestructiveMenuIcon(): Electron.NativeImage | undefined { - if (process.platform !== "darwin") return undefined; - if (destructiveMenuIconCache !== undefined) { - return destructiveMenuIconCache ?? undefined; - } - try { - const icon = nativeImage.createFromNamedImage("trash").resize({ - width: 14, - height: 14, - }); - if (icon.isEmpty()) { - destructiveMenuIconCache = null; - return undefined; - } - icon.setTemplateImage(true); - destructiveMenuIconCache = icon; - return icon; - } catch { - destructiveMenuIconCache = null; - return undefined; - } -} let updatePollTimer: ReturnType | null = null; let updateStartupTimer: ReturnType | null = null; let updateCheckInFlight = false; @@ -1106,67 +1079,6 @@ function registerIpcHandlers(): void { nativeTheme.themeSource = theme; }); - ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); - ipcMain.handle( - CONTEXT_MENU_CHANNEL, - async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => { - const normalizedItems = items - .filter((item) => typeof item.id === "string" && typeof item.label === "string") - .map((item) => ({ - id: item.id, - label: item.label, - destructive: item.destructive === true, - })); - if (normalizedItems.length === 0) { - return null; - } - - const popupPosition = - position && - Number.isFinite(position.x) && - Number.isFinite(position.y) && - position.x >= 0 && - position.y >= 0 - ? { - x: Math.floor(position.x), - y: Math.floor(position.y), - } - : null; - - const window = BrowserWindow.getFocusedWindow() ?? mainWindow; - if (!window) return null; - - return new Promise((resolve) => { - const template: MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - for (const item of normalizedItems) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } - const itemOption: MenuItemConstructorOptions = { - label: item.label, - click: () => resolve(item.id), - }; - if (item.destructive) { - const destructiveIcon = getDestructiveMenuIcon(); - if (destructiveIcon) { - itemOption.icon = destructiveIcon; - } - } - template.push(itemOption); - } - - const menu = Menu.buildFromTemplate(template); - menu.popup({ - window, - ...popupPosition, - callback: () => resolve(null), - }); - }); - }, - ); - ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL); ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => { const externalUrl = getSafeExternalUrl(rawUrl); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1e1bb3bd8..72ba4b69e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -4,7 +4,6 @@ import type { DesktopBridge } from "@t3tools/contracts"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; -const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; @@ -18,7 +17,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), - showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1b43eb4c1..3d3b2e6c9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -11,6 +11,7 @@ import { TriangleAlertIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; +import { flushSync } from "react-dom"; import { DndContext, type DragCancelEvent, @@ -64,6 +65,13 @@ import { import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; import { Collapsible, CollapsibleContent } from "./ui/collapsible"; +import { + ContextMenu, + ContextMenuItem, + ContextMenuPopup, + ContextMenuSeparator, + ContextMenuTrigger, +} from "./ui/context-menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, @@ -118,6 +126,9 @@ interface PrStatusIndicator { } type ThreadPr = GitStatusResult["pr"]; +type ThreadContextMenuMode = "single" | "multi"; +type ThreadContextAction = "rename" | "mark-unread" | "copy-thread-id" | "delete"; +type ProjectContextAction = "delete"; function terminalStatusFromRunningIds( runningTerminalIds: string[], @@ -294,6 +305,10 @@ export default function Sidebar() { const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); + const [activeThreadContextMenu, setActiveThreadContextMenu] = useState<{ + threadId: ThreadId; + mode: ThreadContextMenuMode; + } | null>(null); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const dragInProgressRef = useRef(false); @@ -681,38 +696,28 @@ export default function Sidebar() { }); }, }); - const handleThreadContextMenu = useCallback( - async (threadId: ThreadId, position: { x: number; y: number }) => { + const handleThreadContextAction = useCallback( + async (threadId: ThreadId, action: ThreadContextAction) => { const api = readNativeApi(); if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "rename", label: "Rename thread" }, - { id: "mark-unread", label: "Mark unread" }, - { id: "copy-thread-id", label: "Copy Thread ID" }, - { id: "delete", label: "Delete", destructive: true }, - ], - position, - ); const thread = threads.find((t) => t.id === threadId); if (!thread) return; - if (clicked === "rename") { + if (action === "rename") { setRenamingThreadId(threadId); setRenamingTitle(thread.title); renamingCommittedRef.current = false; return; } - if (clicked === "mark-unread") { + if (action === "mark-unread") { markThreadUnread(threadId); return; } - if (clicked === "copy-thread-id") { + if (action === "copy-thread-id") { copyToClipboard(threadId, { threadId }); return; } - if (clicked !== "delete") return; if (appSettings.confirmThreadDelete) { const confirmed = await api.dialogs.confirm( [ @@ -729,23 +734,15 @@ export default function Sidebar() { [appSettings.confirmThreadDelete, copyToClipboard, deleteThread, markThreadUnread, threads], ); - const handleMultiSelectContextMenu = useCallback( - async (position: { x: number; y: number }) => { + const handleMultiSelectContextAction = useCallback( + async (action: "mark-unread" | "delete") => { const api = readNativeApi(); if (!api) return; const ids = [...selectedThreadIds]; if (ids.length === 0) return; const count = ids.length; - const clicked = await api.contextMenu.show( - [ - { id: "mark-unread", label: `Mark unread (${count})` }, - { id: "delete", label: `Delete (${count})`, destructive: true }, - ], - position, - ); - - if (clicked === "mark-unread") { + if (action === "mark-unread") { for (const id of ids) { markThreadUnread(id); } @@ -753,8 +750,6 @@ export default function Sidebar() { return; } - if (clicked !== "delete") return; - if (appSettings.confirmThreadDelete) { const confirmed = await api.dialogs.confirm( [ @@ -819,15 +814,11 @@ export default function Sidebar() { ], ); - const handleProjectContextMenu = useCallback( - async (projectId: ProjectId, position: { x: number; y: number }) => { + const handleProjectContextAction = useCallback( + async (projectId: ProjectId, action: ProjectContextAction) => { const api = readNativeApi(); if (!api) return; - const clicked = await api.contextMenu.show( - [{ id: "delete", label: "Remove project", destructive: true }], - position, - ); - if (clicked !== "delete") return; + if (action !== "delete") return; const project = projects.find((entry) => entry.id === projectId); if (!project) return; @@ -875,6 +866,27 @@ export default function Sidebar() { ], ); + const prepareThreadContextMenu = useCallback( + (event: MouseEvent, threadId: ThreadId) => { + event.stopPropagation(); + + const mode: ThreadContextMenuMode = + selectedThreadIds.size > 0 && selectedThreadIds.has(threadId) ? "multi" : "single"; + + flushSync(() => { + if (mode === "single" && selectedThreadIds.size > 0) { + clearSelection(); + } + setActiveThreadContextMenu({ threadId, mode }); + }); + }, + [clearSelection, selectedThreadIds], + ); + + const clearActiveThreadContextMenu = useCallback((threadId: ThreadId) => { + setActiveThreadContextMenu((current) => (current?.threadId === threadId ? null : current)); + }, []); + const projectDnDSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 }, @@ -1314,32 +1326,46 @@ export default function Sidebar() { {(dragHandleProps) => (
- handleProjectTitleClick(event, project.id)} - onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} - onContextMenu={(event) => { - event.preventDefault(); - void handleProjectContextMenu(project.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > - - - - {project.name} - - + + handleProjectTitleClick(event, project.id)} + onKeyDown={(event) => + handleProjectTitleKeyDown(event, project.id) + } + /> + } + onContextMenu={(event) => { + event.stopPropagation(); + }} + > + + + + {project.name} + + + + { + void handleProjectContextAction(project.id, "delete"); + }} + > + Remove project + + + { const isActive = routeThreadId === thread.id; const isSelected = selectedThreadIds.has(thread.id); + const threadContextMenuMode = + activeThreadContextMenu?.threadId === thread.id + ? activeThreadContextMenu.mode + : isSelected && selectedThreadIds.size > 0 + ? "multi" + : "single"; const isHighlighted = isActive || isSelected; const threadStatus = resolveThreadStatusPill({ thread, @@ -1402,159 +1434,217 @@ export default function Sidebar() { className="w-full" data-thread-item > - } - size="sm" - isActive={isActive} - className={resolveThreadRowClassName({ - isActive, - isSelected, - })} - onClick={(event) => { - handleThreadClick( - event, - thread.id, - orderedProjectThreadIds, - ); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - if (selectedThreadIds.size > 0) { - clearSelection(); - } - setSelectionAnchor(thread.id); - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }} - onContextMenu={(event) => { - event.preventDefault(); - if ( - selectedThreadIds.size > 0 && - selectedThreadIds.has(thread.id) - ) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - } else { - if (selectedThreadIds.size > 0) { - clearSelection(); - } - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); + { + if (!open) { + clearActiveThreadContextMenu(thread.id); } }} > -
- {prStatus && ( - - { - openPrLink(event, prStatus.url); - }} - > - - + } + size="sm" + isActive={isActive} + className={resolveThreadRowClassName({ + isActive, + isSelected, + })} + onClick={(event) => { + handleThreadClick( + event, + thread.id, + orderedProjectThreadIds, + ); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") { + return; } - /> - - {prStatus.tooltip} - - - )} - {threadStatus && ( - + event.preventDefault(); + if (selectedThreadIds.size > 0) { + clearSelection(); + } + setSelectionAnchor(thread.id); + void navigate({ + to: "/$threadId", + params: { threadId: thread.id }, + }); + }} + /> + } + onContextMenu={(event) => { + prepareThreadContextMenu(event, thread.id); + }} + > +
+ {prStatus && ( + + { + openPrLink(event, prStatus.url); + }} + > + + + } + /> + + {prStatus.tooltip} + + + )} + {threadStatus && ( + + + {threadStatus.label} + + + )} + {renamingThreadId === thread.id ? ( + { + if (el && renamingInputRef.current !== el) { + renamingInputRef.current = el; + el.focus(); + el.select(); + } + }} + className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" + value={renamingTitle} + onChange={(e) => setRenamingTitle(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + renamingCommittedRef.current = true; + void commitRename( + thread.id, + renamingTitle, + thread.title, + ); + } else if (e.key === "Escape") { + e.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }} + onBlur={() => { + if (!renamingCommittedRef.current) { + void commitRename( + thread.id, + renamingTitle, + thread.title, + ); + } + }} + onClick={(e) => e.stopPropagation()} /> - - {threadStatus.label} + ) : ( + + {thread.title} + + )} +
+
+ {terminalStatus && ( + + + )} + + {formatRelativeTime(thread.createdAt)} - )} - {renamingThreadId === thread.id ? ( - { - if (el && renamingInputRef.current !== el) { - renamingInputRef.current = el; - el.focus(); - el.select(); - } - }} - className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" - value={renamingTitle} - onChange={(e) => setRenamingTitle(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - renamingCommittedRef.current = true; - void commitRename( +
+ + + {threadContextMenuMode === "multi" ? ( + <> + { + void handleMultiSelectContextAction("mark-unread"); + }} + > + Mark unread ({selectedThreadIds.size}) + + + { + void handleMultiSelectContextAction("delete"); + }} + > + Delete ({selectedThreadIds.size}) + + + ) : ( + <> + { + void handleThreadContextAction(thread.id, "rename"); + }} + > + Rename thread + + { + void handleThreadContextAction( thread.id, - renamingTitle, - thread.title, + "mark-unread", ); - } else if (e.key === "Escape") { - e.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); - } - }} - onBlur={() => { - if (!renamingCommittedRef.current) { - void commitRename( + }} + > + Mark unread + + { + void handleThreadContextAction( thread.id, - renamingTitle, - thread.title, + "copy-thread-id", ); - } - }} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - - {thread.title} - - )} -
-
- {terminalStatus && ( - - - + }} + > + Copy Thread ID + + + { + void handleThreadContextAction(thread.id, "delete"); + }} + > + Delete + + )} - - {formatRelativeTime(thread.createdAt)} - -
-
+ + ); })} diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx new file mode 100644 index 000000000..c35ffed6b --- /dev/null +++ b/apps/web/src/components/ui/context-menu.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"; + +import { cn } from "~/lib/utils"; + +const ContextMenu = ContextMenuPrimitive.Root; + +function ContextMenuTrigger({ + className, + children, + ...props +}: ContextMenuPrimitive.Trigger.Props) { + return ( + + {children} + + ); +} + +function ContextMenuPopup({ + children, + className, + sideOffset = 4, + align = "center", + alignOffset, + side = "bottom", + ...props +}: ContextMenuPrimitive.Popup.Props & { + align?: ContextMenuPrimitive.Positioner.Props["align"]; + sideOffset?: ContextMenuPrimitive.Positioner.Props["sideOffset"]; + alignOffset?: ContextMenuPrimitive.Positioner.Props["alignOffset"]; + side?: ContextMenuPrimitive.Positioner.Props["side"]; +}) { + return ( + + + +
{children}
+
+
+
+ ); +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: ContextMenuPrimitive.Item.Props & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + svg]:-mx-0.5 flex min-h-8 cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-inset:ps-8 data-[variant=destructive]:text-destructive-foreground data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&>svg:not([class*='opacity-'])]:opacity-80 [&>svg:not([class*='size-'])]:size-4.5 sm:[&>svg:not([class*='size-'])]:size-4 [&>svg]:pointer-events-none [&>svg]:shrink-0", + className, + )} + data-inset={inset} + data-slot="context-menu-item" + data-variant={variant} + {...props} + /> + ); +} + +function ContextMenuSeparator({ + className, + ...props +}: ContextMenuPrimitive.Separator.Props) { + return ( + + ); +} + +export { ContextMenu, ContextMenuItem, ContextMenuPopup, ContextMenuSeparator, ContextMenuTrigger }; diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts deleted file mode 100644 index 63cdef848..000000000 --- a/apps/web/src/contextMenuFallback.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { ContextMenuItem } from "@t3tools/contracts"; - -/** - * Imperative DOM-based context menu for non-Electron environments. - * Shows a positioned dropdown and returns a promise that resolves - * with the clicked item id, or null if dismissed. - */ -export function showContextMenuFallback( - items: readonly ContextMenuItem[], - position?: { x: number; y: number }, -): Promise { - return new Promise((resolve) => { - const overlay = document.createElement("div"); - overlay.style.cssText = "position:fixed;inset:0;z-index:9999"; - - const menu = document.createElement("div"); - menu.className = - "fixed z-[10000] min-w-[140px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; - - const x = position?.x ?? 0; - const y = position?.y ?? 0; - menu.style.top = `${y}px`; - menu.style.left = `${x}px`; - - function cleanup(result: T | null) { - document.removeEventListener("keydown", onKeyDown); - overlay.remove(); - menu.remove(); - resolve(result); - } - - function onKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - e.preventDefault(); - cleanup(null); - } - } - - overlay.addEventListener("mousedown", () => cleanup(null)); - document.addEventListener("keydown", onKeyDown); - - for (const item of items) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.textContent = item.label; - const isDestructiveAction = item.destructive === true || item.id === "delete"; - btn.className = isDestructiveAction - ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" - : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; - btn.addEventListener("click", () => cleanup(item.id)); - menu.appendChild(btn); - } - - document.body.appendChild(overlay); - document.body.appendChild(menu); - - // Adjust if menu overflows viewport - requestAnimationFrame(() => { - const rect = menu.getBoundingClientRect(); - if (rect.right > window.innerWidth) { - menu.style.left = `${window.innerWidth - rect.width - 4}px`; - } - if (rect.bottom > window.innerHeight) { - menu.style.top = `${window.innerHeight - rect.height - 4}px`; - } - }); - }); -} diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 2323380da..80fb7ddc9 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -1,6 +1,5 @@ import { CommandId, - type ContextMenuItem, EventId, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, @@ -18,13 +17,6 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const requestMock = vi.fn<(...args: Array) => Promise>(); -const showContextMenuFallbackMock = - vi.fn< - ( - items: readonly ContextMenuItem[], - position?: { x: number; y: number }, - ) => Promise - >(); const channelListeners = new Map void>>(); const latestPushByChannel = new Map(); const subscribeMock = vi.fn< @@ -61,10 +53,6 @@ vi.mock("./wsTransport", () => { }; }); -vi.mock("./contextMenuFallback", () => ({ - showContextMenuFallback: showContextMenuFallbackMock, -})); - let nextPushSequence = 1; function emitPush(channel: C, data: WsPushData): void { @@ -82,16 +70,6 @@ function emitPush(channel: C, data: WsPushData): voi } } -function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unknown } { - const testGlobal = globalThis as typeof globalThis & { - window?: Window & typeof globalThis & { desktopBridge?: unknown }; - }; - if (!testGlobal.window) { - testGlobal.window = {} as Window & typeof globalThis & { desktopBridge?: unknown }; - } - return testGlobal.window; -} - const defaultProviders: ReadonlyArray = [ { provider: "codex", @@ -105,12 +83,10 @@ const defaultProviders: ReadonlyArray = [ beforeEach(() => { vi.resetModules(); requestMock.mockReset(); - showContextMenuFallbackMock.mockReset(); subscribeMock.mockClear(); channelListeners.clear(); latestPushByChannel.clear(); nextPushSequence = 1; - Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); }); afterEach(() => { @@ -335,50 +311,4 @@ describe("wsNativeApi", () => { toTurnCount: 1, }); }); - - it("forwards context menu metadata to desktop bridge", async () => { - const showContextMenu = vi.fn().mockResolvedValue("delete"); - Object.defineProperty(getWindowForTest(), "desktopBridge", { - configurable: true, - writable: true, - value: { - showContextMenu, - }, - }); - - const { createWsNativeApi } = await import("./wsNativeApi"); - const api = createWsNativeApi(); - await api.contextMenu.show( - [ - { id: "rename", label: "Rename thread" }, - { id: "delete", label: "Delete", destructive: true }, - ], - { x: 200, y: 300 }, - ); - - expect(showContextMenu).toHaveBeenCalledWith( - [ - { id: "rename", label: "Rename thread" }, - { id: "delete", label: "Delete", destructive: true }, - ], - { x: 200, y: 300 }, - ); - }); - - it("uses fallback context menu when desktop bridge is unavailable", async () => { - showContextMenuFallbackMock.mockResolvedValue("delete"); - Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); - - const { createWsNativeApi } = await import("./wsNativeApi"); - const api = createWsNativeApi(); - await api.contextMenu.show([{ id: "delete", label: "Delete", destructive: true }], { - x: 20, - y: 30, - }); - - expect(showContextMenuFallbackMock).toHaveBeenCalledWith( - [{ id: "delete", label: "Delete", destructive: true }], - { x: 20, y: 30 }, - ); - }); }); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde6..dc6051ade 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -1,7 +1,6 @@ import { ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, - type ContextMenuItem, type NativeApi, ServerConfigUpdatedPayload, WS_CHANNELS, @@ -9,7 +8,6 @@ import { type WsWelcomePayload, } from "@t3tools/contracts"; -import { showContextMenuFallback } from "./contextMenuFallback"; import { WsTransport } from "./wsTransport"; let instance: { api: NativeApi; transport: WsTransport } | null = null; @@ -146,17 +144,6 @@ export function createWsNativeApi(): NativeApi { preparePullRequestThread: (input) => transport.request(WS_METHODS.gitPreparePullRequestThread, input), }, - contextMenu: { - show: async ( - items: readonly ContextMenuItem[], - position?: { x: number; y: number }, - ): Promise => { - if (window.desktopBridge) { - return window.desktopBridge.showContextMenu(items, position) as Promise; - } - return showContextMenuFallback(items, position); - }, - }, server: { getConfig: () => transport.request(WS_METHODS.serverGetConfig), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb17..adc324f6f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -47,12 +47,6 @@ import type { } from "./orchestration"; import { EditorId } from "./editor"; -export interface ContextMenuItem { - id: T; - label: string; - destructive?: boolean; -} - export type DesktopUpdateStatus = | "disabled" | "idle" @@ -99,10 +93,6 @@ export interface DesktopBridge { pickFolder: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; - showContextMenu: ( - items: readonly ContextMenuItem[], - position?: { x: number; y: number }, - ) => Promise; openExternal: (url: string) => Promise; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; @@ -150,12 +140,6 @@ export interface NativeApi { status: (input: GitStatusInput) => Promise; runStackedAction: (input: GitRunStackedActionInput) => Promise; }; - contextMenu: { - show: ( - items: readonly ContextMenuItem[], - position?: { x: number; y: number }, - ) => Promise; - }; server: { getConfig: () => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise;