From 1bd9c9502685733d8ac264a8e19dcd1fd7476415 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Mon, 10 Aug 2026 22:08:07 +0800 Subject: [PATCH 1/2] fix(ui): prevent native menu deadlock --- .../FileTreeContent/FileExplorerMenu.tsx | 11 +- src/components/WindowChrome/WindowsTopBar.tsx | 13 +- .../ChatPanel/ChatPanelTabContextMenu.tsx | 11 +- .../hooks/useKanbanCardContextMenu.ts | 35 ++--- src/hooks/ui/useResizeContextMenu.ts | 5 +- .../GitHistoryContextMenu.tsx | 11 +- .../components/SourceControlContextMenu.tsx | 11 +- .../shared/TabBar/TabContextMenu.tsx | 11 +- .../components/SpotlightItemRow.tsx | 11 +- .../NavigationSidebar/SidebarBase.tsx | 5 +- .../channelsSection.tsx | 24 ++-- .../cloudSessionsSection.rowItemBuilder.tsx | 71 +++++----- .../localChannelsSection.tsx | 18 +-- .../useWorkstationSidebarContextMenu.ts | 19 ++- .../tauri/nativeMenuSingleFlight.test.ts | 126 ++++++++++++++++++ .../platform/tauri/nativeMenuSingleFlight.ts | 72 ++++++++++ 16 files changed, 368 insertions(+), 86 deletions(-) create mode 100644 src/util/platform/tauri/nativeMenuSingleFlight.test.ts create mode 100644 src/util/platform/tauri/nativeMenuSingleFlight.ts diff --git a/src/components/FileTreeContent/FileExplorerMenu.tsx b/src/components/FileTreeContent/FileExplorerMenu.tsx index 711daa3f5d..1b26fedae4 100644 --- a/src/components/FileTreeContent/FileExplorerMenu.tsx +++ b/src/components/FileTreeContent/FileExplorerMenu.tsx @@ -22,6 +22,7 @@ import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; import { copyText } from "@src/util/data/clipboard"; import { confirmDestructiveAction } from "@src/util/dialogs/confirmDestructiveAction"; import { getFileManagerRevealLabelKey } from "@src/util/platform/fileManagerLabels"; +import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; import type { DispatchFn } from "./types"; @@ -87,7 +88,7 @@ export function FileExplorerContextMenu(props: FileExplorerContextMenuProps) { if (hasShownMenu.current) return; hasShownMenu.current = true; - async function showNativeMenu(): Promise { + async function showNativeMenuUnchecked(): Promise { try { const items: (MenuItem | PredefinedMenuItem | Submenu)[] = []; const translate = i18next.t.bind(i18next); @@ -362,6 +363,14 @@ export function FileExplorerContextMenu(props: FileExplorerContextMenuProps) { } } + async function showNativeMenu(): Promise { + const result = await runNativeMenuSingleFlight( + "file-explorer", + showNativeMenuUnchecked + ); + if (result.status === "busy") onClose(); + } + showNativeMenu(); }, [node, repoPath, dispatch, onClose]); diff --git a/src/components/WindowChrome/WindowsTopBar.tsx b/src/components/WindowChrome/WindowsTopBar.tsx index 944ca8d681..405f7b3b94 100644 --- a/src/components/WindowChrome/WindowsTopBar.tsx +++ b/src/components/WindowChrome/WindowsTopBar.tsx @@ -16,6 +16,7 @@ import { maxWindow, minWindow, } from "@src/util/platform/ipcRenderer"; +import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; import { NoDragRegion } from "./NoDragRegion"; @@ -247,7 +248,7 @@ function getMenuItems(menu: NativeMenuKey, t: TFunction): NativeMenuItem[] { } } -async function showNativeStyleMenu( +async function showNativeStyleMenuUnchecked( menuKey: NativeMenuKey, anchor: HTMLElement, t: TFunction @@ -279,6 +280,16 @@ async function showNativeStyleMenu( } } +async function showNativeStyleMenu( + menuKey: NativeMenuKey, + anchor: HTMLElement, + t: TFunction +) { + await runNativeMenuSingleFlight(`windows-top-bar:${menuKey}`, () => + showNativeStyleMenuUnchecked(menuKey, anchor, t) + ); +} + const WindowsTopBarComponent: React.FC = () => { const activeLanguage = useSyncExternalStore( subscribeToLanguageChange, diff --git a/src/engines/ChatPanel/ChatPanelTabContextMenu.tsx b/src/engines/ChatPanel/ChatPanelTabContextMenu.tsx index 7b3aeb0eb0..5b1762e542 100644 --- a/src/engines/ChatPanel/ChatPanelTabContextMenu.tsx +++ b/src/engines/ChatPanel/ChatPanelTabContextMenu.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef } from "react"; import { createLogger } from "@src/hooks/logger"; import type { SessionReferenceOpen } from "@src/shared/dnd/sessionTabDrag"; +import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; const logger = createLogger("ChatPanelTabContextMenu"); @@ -31,7 +32,7 @@ export function ChatPanelTabContextMenu( if (hasShownMenu.current) return; hasShownMenu.current = true; - async function showNativeMenu(): Promise { + async function showNativeMenuUnchecked(): Promise { try { const translate = i18next.t.bind(i18next); const [closeItem, closeOthersItem] = await Promise.all([ @@ -80,6 +81,14 @@ export function ChatPanelTabContextMenu( } } + async function showNativeMenu(): Promise { + const result = await runNativeMenuSingleFlight( + "chat-panel-tab", + showNativeMenuUnchecked + ); + if (result.status === "busy") propsRef.current.onDismiss(); + } + void showNativeMenu(); }, []); diff --git a/src/features/TaskKanban/hooks/useKanbanCardContextMenu.ts b/src/features/TaskKanban/hooks/useKanbanCardContextMenu.ts index 5b98ed40bf..462b04684a 100644 --- a/src/features/TaskKanban/hooks/useKanbanCardContextMenu.ts +++ b/src/features/TaskKanban/hooks/useKanbanCardContextMenu.ts @@ -14,6 +14,7 @@ import type { KanbanTask } from "@src/features/KanbanBoard"; import { createLogger } from "@src/hooks/logger"; import { openOrFocusSessionInChatPanelTabAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; import { activeStationChatVisibleAtom } from "@src/store/ui/chatPanelAtom"; +import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; import { KANBAN_CARD_CONTEXT_ACTION, @@ -62,28 +63,30 @@ export function useKanbanCardContextMenu({ }); if (actions.length === 0) return; - const items: MenuItem[] = []; - for (const action of actions) { - if (action === KANBAN_CARD_CONTEXT_ACTION.OpenFloatingPane) { + await runNativeMenuSingleFlight("kanban-card", async () => { + const items: MenuItem[] = []; + for (const action of actions) { + if (action === KANBAN_CARD_CONTEXT_ACTION.OpenFloatingPane) { + items.push( + await MenuItem.new({ + text: t("kanban.card.openAsFloatingPane"), + action: () => onOpenFloatingPane(task), + }) + ); + continue; + } + if (!sessionId) continue; items.push( await MenuItem.new({ - text: t("kanban.card.openAsFloatingPane"), - action: () => onOpenFloatingPane(task), + text: tCommon("actions.openInNewTab"), + action: () => openInNewTabPane(sessionId, task.title), }) ); - continue; } - if (!sessionId) continue; - items.push( - await MenuItem.new({ - text: tCommon("actions.openInNewTab"), - action: () => openInNewTabPane(sessionId, task.title), - }) - ); - } - const menu = await TauriMenu.new({ items }); - await menu.popup(); + const menu = await TauriMenu.new({ items }); + await menu.popup(); + }); }, [onOpenFloatingPane, openInNewTabPane, remoteSessionsByTaskId, t, tCommon] ); diff --git a/src/hooks/ui/useResizeContextMenu.ts b/src/hooks/ui/useResizeContextMenu.ts index 3e0eda8b7e..1fe997d4e8 100644 --- a/src/hooks/ui/useResizeContextMenu.ts +++ b/src/hooks/ui/useResizeContextMenu.ts @@ -19,6 +19,7 @@ import i18next from "i18next"; import { type MouseEvent, useCallback } from "react"; import { createLogger } from "@src/hooks/logger"; +import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; const log = createLogger("useResizeContextMenu"); @@ -79,7 +80,7 @@ export function useResizeContextMenu({ const interpolateMin = dimension === "width" ? { width: minSize } : { height: minSize }; - (async () => { + void runNativeMenuSingleFlight("resize-handle", async () => { try { const resizeDefaultItem = await MenuItem.new({ text: i18next.t(keys.resizeToDefault, interpolate), @@ -134,7 +135,7 @@ export function useResizeContextMenu({ } catch (error) { log.error("Failed to show resize context menu:", error); } - })(); + }); }, [ dimension, diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/GitHistoryContent/GitHistoryContextMenu.tsx b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/GitHistoryContent/GitHistoryContextMenu.tsx index bd82a9d325..411c7dd3ae 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/GitHistoryContent/GitHistoryContextMenu.tsx +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/GitHistoryContent/GitHistoryContextMenu.tsx @@ -12,6 +12,7 @@ import { copyText } from "@src/util/data/clipboard"; import { confirmDestructiveAction } from "@src/util/dialogs/confirmDestructiveAction"; import { showGitActionDialogSafely } from "@src/util/dialogs/gitActionDialog"; import { openExternalLink } from "@src/util/platform/ipcRenderer"; +import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; const log = createLogger("GitHistoryContextMenu"); @@ -126,7 +127,7 @@ export default function GitHistoryContextMenu( onActionComplete, } = props; - async function showNativeMenu() { + async function showNativeMenuUnchecked() { try { const t = i18next.t.bind(i18next); @@ -361,6 +362,14 @@ export default function GitHistoryContextMenu( } } + async function showNativeMenu() { + const result = await runNativeMenuSingleFlight( + "git-history", + showNativeMenuUnchecked + ); + if (result.status === "busy") onClose(); + } + showNativeMenu(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/SourceControlContent/components/SourceControlContextMenu.tsx b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/SourceControlContent/components/SourceControlContextMenu.tsx index 3d60a7da68..d0c48dd889 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/SourceControlContent/components/SourceControlContextMenu.tsx +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/SourceControlContent/components/SourceControlContextMenu.tsx @@ -19,6 +19,7 @@ import { createLogger } from "@src/hooks/logger"; import type { GitFile } from "@src/types/git/types"; import { copyText } from "@src/util/data/clipboard"; import { getFileManagerRevealLabelKey } from "@src/util/platform/fileManagerLabels"; +import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; import { GIT_LABELS } from "../config"; @@ -115,7 +116,7 @@ export default function SourceControlContextMenu( if (hasShownMenu.current) return; hasShownMenu.current = true; - async function showNativeMenu() { + async function showNativeMenuUnchecked() { try { const ctx = contextMenuRef.current; if (!ctx) { @@ -323,6 +324,14 @@ export default function SourceControlContextMenu( } } + async function showNativeMenu() { + const result = await runNativeMenuSingleFlight( + "source-control", + showNativeMenuUnchecked + ); + if (result.status === "busy") onClose(); + } + showNativeMenu(); }, [onClose]); diff --git a/src/modules/WorkStation/shared/TabBar/TabContextMenu.tsx b/src/modules/WorkStation/shared/TabBar/TabContextMenu.tsx index 77c37b9445..e40bca69c8 100644 --- a/src/modules/WorkStation/shared/TabBar/TabContextMenu.tsx +++ b/src/modules/WorkStation/shared/TabBar/TabContextMenu.tsx @@ -18,6 +18,7 @@ import { useEffect, useRef } from "react"; import { createLogger } from "@src/hooks/logger"; import { copyText } from "@src/util/data/clipboard"; import { getFileManagerRevealLabelKey } from "@src/util/platform/fileManagerLabels"; +import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; import type { WorkStationTab } from "./types"; @@ -157,7 +158,7 @@ export function TabContextMenu(props: TabContextMenuProps) { if (hasShownMenu.current) return; hasShownMenu.current = true; - async function showNativeMenu() { + async function showNativeMenuUnchecked() { try { // Create menu items in parallel - each MenuItem.new() is an async IPC call const t = i18next.t.bind(i18next); @@ -342,6 +343,14 @@ export function TabContextMenu(props: TabContextMenuProps) { } } + async function showNativeMenu() { + const result = await runNativeMenuSingleFlight( + "workstation-tab", + showNativeMenuUnchecked + ); + if (result.status === "busy") onClose(); + } + showNativeMenu(); }, [filePath, onClose, tab.data.sessionId, tab.type]); diff --git a/src/scaffold/GlobalSpotlight/components/SpotlightItemRow.tsx b/src/scaffold/GlobalSpotlight/components/SpotlightItemRow.tsx index 561866f454..ea4550af31 100644 --- a/src/scaffold/GlobalSpotlight/components/SpotlightItemRow.tsx +++ b/src/scaffold/GlobalSpotlight/components/SpotlightItemRow.tsx @@ -28,6 +28,7 @@ import Tooltip from "@src/components/Tooltip"; import { createLogger } from "@src/hooks/logger"; import { copyText } from "@src/util/data/clipboard"; import { getFileManagerRevealLabelKey } from "@src/util/platform/fileManagerLabels"; +import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; import { ICONS } from "../config"; import { SPOTLIGHT_TOKENS } from "../constants"; @@ -56,7 +57,7 @@ interface SpotlightContextMenuOptions { revealLabel: string; } -async function showSpotlightContextMenu({ +async function showSpotlightContextMenuUnchecked({ name, path, copyNameLabel, @@ -106,6 +107,14 @@ async function showSpotlightContextMenu({ await menu.popup(); } +async function showSpotlightContextMenu( + options: SpotlightContextMenuOptions +): Promise { + await runNativeMenuSingleFlight("global-spotlight", () => + showSpotlightContextMenuUnchecked(options) + ); +} + interface PathParts { prefix: string; suffix: string; diff --git a/src/scaffold/NavigationSidebar/SidebarBase.tsx b/src/scaffold/NavigationSidebar/SidebarBase.tsx index 122a738dd8..b08096ca0d 100644 --- a/src/scaffold/NavigationSidebar/SidebarBase.tsx +++ b/src/scaffold/NavigationSidebar/SidebarBase.tsx @@ -47,6 +47,7 @@ import { } from "@src/store/ui/sidebarAtom"; import { windowFullscreenAtom } from "@src/store/ui/uiAtom"; import { isTauriDesktop } from "@src/util/platform/tauri"; +import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; import { SIDEBAR_STYLE } from "./config"; import { useForceVisibleSidebar } from "./contexts/ForceVisibleContext"; @@ -168,7 +169,7 @@ const SidebarBase: React.FC = React.memo( const isAlreadyDefault = sidebarWidth === DEFAULT_SIDEBAR_WIDTH; const isAlreadyMin = sidebarWidth <= MIN_SIDEBAR_WIDTH; - (async () => { + void runNativeMenuSingleFlight("navigation-sidebar", async () => { try { const t = i18next.t.bind(i18next); @@ -206,7 +207,7 @@ const SidebarBase: React.FC = React.memo( } catch (error) { log.error("Failed to show sidebar context menu:", error); } - })(); + }); }, [sidebarWidth, setWidth, collapse] ); diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/channelsSection.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/channelsSection.tsx index c2058384d6..2da79b893b 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/channelsSection.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/channelsSection.tsx @@ -49,6 +49,7 @@ import { openChannelInChatPanelTabAtom, reconcileDiscussionChannelTabsAtom, } from "@src/store/chatPanel/chatPanelTabsAtom"; +import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; import { CLOUD_CHANNELS_EMPTY_ID, @@ -244,18 +245,17 @@ export function useCloudChannelsSection({ }, ] : []; - void Promise.all( - [...settingsEntries, ...kinds.map((kind) => entries[kind])].map( - (entry) => MenuItem.new(entry) - ) - ) - .then(async (menuItems) => { - const menu = await TauriMenu.new({ items: menuItems }); - await menu.popup(); - }) - .catch((error) => { - log.warn("channel row menu failed to open:", error); - }); + void runNativeMenuSingleFlight("cloud-channel-row", async () => { + const menuItems = await Promise.all( + [...settingsEntries, ...kinds.map((kind) => entries[kind])].map( + (entry) => MenuItem.new(entry) + ) + ); + const menu = await TauriMenu.new({ items: menuItems }); + await menu.popup(); + }).catch((error) => { + log.warn("channel row menu failed to open:", error); + }); }, [isOrgAdmin, openMembersDialog, orgId, t] ); diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.rowItemBuilder.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.rowItemBuilder.tsx index 2c015e0be0..1e819cc768 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.rowItemBuilder.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.rowItemBuilder.tsx @@ -35,6 +35,7 @@ import { useCloudSessionDownloadProgressEntry } from "@src/features/Org2Cloud/us import type { NavigationMenuItem } from "@src/scaffold/NavigationSidebar/components/NavigationMenu/config"; import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; import { copyText } from "@src/util/data/clipboard"; +import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; import { resolveSessionDisplayMetadata } from "@src/util/session/sessionDisplayMetadata"; import { formatRelativeTime } from "@src/util/time/formatRelativeTime"; @@ -277,40 +278,42 @@ export function useCloudSessionRowItemBuilder({ icon: MoreHorizontal, label: tCommon("actions.more"), onClick: () => { - void Promise.all([ - MenuItem.new({ - text: t("cloud.sidebar.copyId"), - action: () => { - void copyText(buildCloudSessionReference(row)) - .then(() => { - Message.success(tCommon("actions.copied", "Copied")); - }) - .catch(() => { - Message.error( - tCommon("actions.copyFailed", "Copy failed") - ); - }); - }, - }), - MenuItem.new({ - text: isPinned - ? tCommon("sessions:chat.unpinSession", "Unpin") - : tCommon("sessions:chat.pinSession", "Pin"), - action: () => toggleRemoteSessionPin(row.orgId, row.id), - }), - PredefinedMenuItem.new({ item: "Separator" }), - MenuItem.new({ - text: tCommon("actions.remove", "Remove"), - action: () => hideRemoteSession(row), - }), - ]).then( - async ([copyItem, pinItem, menuSeparator, removeItem]) => { - const menu = await TauriMenu.new({ - items: [copyItem, pinItem, menuSeparator, removeItem], - }); - await menu.popup(); - } - ); + void runNativeMenuSingleFlight("cloud-session-row", async () => { + const [copyItem, pinItem, menuSeparator, removeItem] = + await Promise.all([ + MenuItem.new({ + text: t("cloud.sidebar.copyId"), + action: () => { + void copyText(buildCloudSessionReference(row)) + .then(() => { + Message.success( + tCommon("actions.copied", "Copied") + ); + }) + .catch(() => { + Message.error( + tCommon("actions.copyFailed", "Copy failed") + ); + }); + }, + }), + MenuItem.new({ + text: isPinned + ? tCommon("sessions:chat.unpinSession", "Unpin") + : tCommon("sessions:chat.pinSession", "Pin"), + action: () => toggleRemoteSessionPin(row.orgId, row.id), + }), + PredefinedMenuItem.new({ item: "Separator" }), + MenuItem.new({ + text: tCommon("actions.remove", "Remove"), + action: () => hideRemoteSession(row), + }), + ]); + const menu = await TauriMenu.new({ + items: [copyItem, pinItem, menuSeparator, removeItem], + }); + await menu.popup(); + }); }, }, ]; diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/localChannelsSection.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/localChannelsSection.tsx index d430cce445..2a43dd461a 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/localChannelsSection.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/localChannelsSection.tsx @@ -39,6 +39,7 @@ import { reconcileLocalChannelMessagesAtom, unarchiveLocalChannelAtom, } from "@src/store/ui/localChannelsAtom"; +import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; import { LOCAL_CHANNELS_EMPTY_ID, @@ -181,14 +182,15 @@ export function useLocalChannelsSection({ action: () => setDialogState({ kind: "delete", channel }), }, ]; - void Promise.all(entries.map((entry) => MenuItem.new(entry))) - .then(async (menuItems) => { - const menu = await TauriMenu.new({ items: menuItems }); - await menu.popup(); - }) - .catch((error) => { - log.warn("local channel row menu failed to open:", error); - }); + void runNativeMenuSingleFlight("local-channel-row", async () => { + const menuItems = await Promise.all( + entries.map((entry) => MenuItem.new(entry)) + ); + const menu = await TauriMenu.new({ items: menuItems }); + await menu.popup(); + }).catch((error) => { + log.warn("local channel row menu failed to open:", error); + }); }, [t] ); diff --git a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarContextMenu.ts b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarContextMenu.ts index 159aae2c11..e5f04efcd2 100644 --- a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarContextMenu.ts +++ b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarContextMenu.ts @@ -8,6 +8,7 @@ import { type MouseEvent, useCallback } from "react"; import { createLogger } from "@src/hooks/logger"; import type { NavigationMenuItem } from "@src/scaffold/NavigationSidebar/components/NavigationMenu/config"; import type { Session } from "@src/store/session"; +import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; import { isCursorIdeSession, isHumanSession, @@ -85,11 +86,8 @@ export function useWorkstationSidebarContextMenu({ _key: string, item: NavigationMenuItem ) => Promise { - return useCallback( - async (event: MouseEvent, _key: string, item: NavigationMenuItem) => { - event.preventDefault(); - event.stopPropagation(); - + const showMenuUnchecked = useCallback( + async (_event: MouseEvent, _key: string, item: NavigationMenuItem) => { if (isDraftMenuItemId(item.id)) { const draftId = getDraftIdFromMenuItemId(item.id); if (!draftId) return; @@ -254,4 +252,15 @@ export function useWorkstationSidebarContextMenu({ handleCloudRemoteItemRemove, ] ); + + return useCallback( + async (event: MouseEvent, key: string, item: NavigationMenuItem) => { + event.preventDefault(); + event.stopPropagation(); + await runNativeMenuSingleFlight("workstation-sidebar-row", () => + showMenuUnchecked(event, key, item) + ); + }, + [showMenuUnchecked] + ); } diff --git a/src/util/platform/tauri/nativeMenuSingleFlight.test.ts b/src/util/platform/tauri/nativeMenuSingleFlight.test.ts new file mode 100644 index 0000000000..d6949dbc76 --- /dev/null +++ b/src/util/platform/tauri/nativeMenuSingleFlight.test.ts @@ -0,0 +1,126 @@ +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { runNativeMenuSingleFlight } from "./nativeMenuSingleFlight"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function listSourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return listSourceFiles(entryPath); + return /\.tsx?$/.test(entry.name) ? [entryPath] : []; + }); +} + +describe("runNativeMenuSingleFlight", () => { + afterEach(() => { + vi.resetModules(); + }); + + it("drops a concurrent request before invoking its menu factory", async () => { + const firstRun = deferred(); + const firstTask = vi.fn(() => firstRun.promise); + const duplicateTask = vi.fn(async () => undefined); + + const firstResultPromise = runNativeMenuSingleFlight( + "file-explorer", + firstTask + ); + const duplicateResult = await runNativeMenuSingleFlight( + "tab-context-menu", + duplicateTask + ); + + expect(firstTask).toHaveBeenCalledOnce(); + expect(duplicateTask).not.toHaveBeenCalled(); + expect(duplicateResult).toEqual({ + status: "busy", + activeSource: "file-explorer", + }); + + firstRun.resolve(); + await expect(firstResultPromise).resolves.toEqual({ + status: "completed", + value: undefined, + }); + }); + + it("accepts a new request after the active popup completes", async () => { + const firstRun = deferred(); + const firstResultPromise = runNativeMenuSingleFlight( + "first-menu", + () => firstRun.promise + ); + firstRun.resolve(); + await firstResultPromise; + + const nextTask = vi.fn(async () => "opened"); + await expect( + runNativeMenuSingleFlight("next-menu", nextTask) + ).resolves.toEqual({ status: "completed", value: "opened" }); + expect(nextTask).toHaveBeenCalledOnce(); + }); + + it("releases the gate when menu construction or popup rejects", async () => { + const failure = new Error("popup failed"); + + await expect( + runNativeMenuSingleFlight("broken-menu", async () => { + throw failure; + }) + ).rejects.toBe(failure); + + await expect( + runNativeMenuSingleFlight("recovery-menu", async () => "recovered") + ).resolves.toEqual({ status: "completed", value: "recovered" }); + }); + + it("shares the active gate across hot module reloads", async () => { + const activeRun = deferred(); + const firstResultPromise = runNativeMenuSingleFlight( + "pre-reload-menu", + () => activeRun.promise + ); + + vi.resetModules(); + const reloadedModule = await import("./nativeMenuSingleFlight"); + const reloadedTask = vi.fn(async () => undefined); + await expect( + reloadedModule.runNativeMenuSingleFlight("post-reload-menu", reloadedTask) + ).resolves.toEqual({ + status: "busy", + activeSource: "pre-reload-menu", + }); + expect(reloadedTask).not.toHaveBeenCalled(); + + activeRun.resolve(); + await firstResultPromise; + }); + + it("keeps every native popup entry point behind the coordinator", () => { + const sourceRoot = path.resolve(process.cwd(), "src"); + const unguardedFiles = listSourceFiles(sourceRoot) + .filter((file) => !file.endsWith(".test.ts")) + .filter((file) => { + const source = readFileSync(file, "utf8"); + return ( + source.includes("@tauri-apps/api/menu") && + /\.popup\s*\(/.test(source) && + !source.includes("runNativeMenuSingleFlight") + ); + }) + .map((file) => path.relative(sourceRoot, file)); + + expect(unguardedFiles).toEqual([]); + }); +}); diff --git a/src/util/platform/tauri/nativeMenuSingleFlight.ts b/src/util/platform/tauri/nativeMenuSingleFlight.ts new file mode 100644 index 0000000000..eb36530876 --- /dev/null +++ b/src/util/platform/tauri/nativeMenuSingleFlight.ts @@ -0,0 +1,72 @@ +/** + * Serializes native menu construction and popup work within one WebView. + * + * Tauri's menu popup command retains the WebView resource-table lock while the + * native menu is tracking input. A nested menu request can otherwise re-enter + * the same WebView and wait forever while the popup waits on the UI thread. + * Duplicate requests are intentionally dropped instead of queued because a + * context menu opened after the original interaction has ended is stale UI. + */ + +export interface NativeMenuSingleFlightBusy { + status: "busy"; + activeSource: string; +} + +export interface NativeMenuSingleFlightCompleted { + status: "completed"; + value: T; +} + +export type NativeMenuSingleFlightResult = + | NativeMenuSingleFlightBusy + | NativeMenuSingleFlightCompleted; + +interface ActiveNativeMenuRun { + source: string; + token: object; +} + +interface NativeMenuSingleFlightState { + active: ActiveNativeMenuRun | null; +} + +const NATIVE_MENU_STATE_KEY = Symbol.for( + "orgii.tauri.native-menu-single-flight.v1" +); + +function getState(): NativeMenuSingleFlightState { + const host = globalThis as unknown as Record; + const existing = host[NATIVE_MENU_STATE_KEY]; + if (existing) return existing as NativeMenuSingleFlightState; + + const state: NativeMenuSingleFlightState = { active: null }; + host[NATIVE_MENU_STATE_KEY] = state; + return state; +} + +/** + * Runs all resource creation and popup work for one native menu as a single + * non-queueing critical section. The gate is claimed synchronously before the + * task starts, including before its first Tauri IPC call. + */ +export async function runNativeMenuSingleFlight( + source: string, + task: () => Promise +): Promise> { + const state = getState(); + if (state.active) { + return { status: "busy", activeSource: state.active.source }; + } + + const token = {}; + state.active = { source, token }; + + try { + return { status: "completed", value: await task() }; + } finally { + if (state.active?.token === token) { + state.active = null; + } + } +} From 1dbad7e50d4b8840195e5f382b69b5444d5eb976 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Mon, 10 Aug 2026 22:56:06 +0800 Subject: [PATCH 2/2] refactor(ui): centralize native menu lifecycle --- .../FileTreeContent/FileExplorerMenu.tsx | 494 +++++++++--------- src/components/WindowChrome/WindowsTopBar.tsx | 61 +-- .../ChatPanel/ChatPanelTabContextMenu.tsx | 98 ++-- .../hooks/useKanbanCardContextMenu.ts | 41 +- src/hooks/ui/useResizeContextMenu.ts | 97 ++-- .../GitHistoryContextMenu.tsx | 440 +++++++--------- .../components/SourceControlContextMenu.tsx | 369 ++++++------- .../shared/TabBar/TabContextMenu.tsx | 321 ++++++------ .../components/SpotlightItemRow.tsx | 101 ++-- .../NavigationSidebar/SidebarBase.tsx | 71 ++- .../channelsSection.orgSwitch.test.ts | 16 +- .../channelsSection.tsx | 17 +- .../cloudSessionsSection.rowItemBuilder.tsx | 71 ++- .../localChannelsSection.tsx | 12 +- .../useWorkstationSidebarContextMenu.ts | 220 ++++---- .../platform/tauri/nativeMenuPopup.test.ts | 269 ++++++++++ src/util/platform/tauri/nativeMenuPopup.ts | 152 ++++++ .../tauri/nativeMenuSingleFlight.test.ts | 126 ----- .../platform/tauri/nativeMenuSingleFlight.ts | 72 --- 19 files changed, 1528 insertions(+), 1520 deletions(-) create mode 100644 src/util/platform/tauri/nativeMenuPopup.test.ts create mode 100644 src/util/platform/tauri/nativeMenuPopup.ts delete mode 100644 src/util/platform/tauri/nativeMenuSingleFlight.test.ts delete mode 100644 src/util/platform/tauri/nativeMenuSingleFlight.ts diff --git a/src/components/FileTreeContent/FileExplorerMenu.tsx b/src/components/FileTreeContent/FileExplorerMenu.tsx index 1b26fedae4..a1ee253a14 100644 --- a/src/components/FileTreeContent/FileExplorerMenu.tsx +++ b/src/components/FileTreeContent/FileExplorerMenu.tsx @@ -6,12 +6,6 @@ * * Uses dispatch() for actions per GUI Action System guidelines. */ -import { - MenuItem, - PredefinedMenuItem, - Submenu, - Menu as TauriMenu, -} from "@tauri-apps/api/menu"; import i18next from "i18next"; import { useEffect, useRef } from "react"; @@ -22,7 +16,10 @@ import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; import { copyText } from "@src/util/data/clipboard"; import { confirmDestructiveAction } from "@src/util/dialogs/confirmDestructiveAction"; import { getFileManagerRevealLabelKey } from "@src/util/platform/fileManagerLabels"; -import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; +import { + type NativeMenuItemOptions, + popupNativeMenu, +} from "@src/util/platform/tauri/nativeMenuPopup"; import type { DispatchFn } from "./types"; @@ -88,238 +85,212 @@ export function FileExplorerContextMenu(props: FileExplorerContextMenuProps) { if (hasShownMenu.current) return; hasShownMenu.current = true; - async function showNativeMenuUnchecked(): Promise { + async function showNativeMenu(): Promise { try { - const items: (MenuItem | PredefinedMenuItem | Submenu)[] = []; - const translate = i18next.t.bind(i18next); - - const [newFileItem, newFolderItem] = await Promise.all([ - MenuItem.new({ - text: translate("actions.newFile", { defaultValue: "New File" }), - accelerator: "CmdOrCtrl+N", - action: () => { - if (contextMenuRef.current) { - const targetDirectory = getTargetDirectory( - contextMenuRef.current.node, - contextMenuRef.current.repoPath - ); - const closeMenu = contextMenuRef.current.onClose; - const startCreateNew = contextMenuRef.current.onStartCreateNew; - closeMenu(); - if (startCreateNew) { - requestAnimationFrame(() => - startCreateNew(targetDirectory, false) - ); - } - } - }, - }), - MenuItem.new({ - text: translate("actions.newFolder", { - defaultValue: "New Folder", - }), - accelerator: "CmdOrCtrl+Shift+N", - action: () => { - if (contextMenuRef.current) { - const targetDirectory = getTargetDirectory( - contextMenuRef.current.node, - contextMenuRef.current.repoPath - ); - const closeMenu = contextMenuRef.current.onClose; - const startCreateNew = contextMenuRef.current.onStartCreateNew; - closeMenu(); - if (startCreateNew) { - requestAnimationFrame(() => - startCreateNew(targetDirectory, true) - ); - } - } - }, - }), - ]); - - items.push(newFileItem, newFolderItem); - - if (node) { - const store = getInstrumentedStore(); - const clipboard = store.get(fileClipboardAtom); - const hasPasteItems = clipboard && clipboard.paths.length > 0; - - const [ - separatorBeforeNodeActions, - renameItem, - deleteItem, - duplicateItem, - separatorBeforeClipboardActions, - copyItem, - pasteItem, - separatorBeforePathActions, - copyPathItem, - copyRelativePathItem, - separatorBeforeRevealActions, - revealFinderItem, - ] = await Promise.all([ - PredefinedMenuItem.new({ item: "Separator" }), - MenuItem.new({ - text: translate("actions.rename", { defaultValue: "Rename" }), - accelerator: "Enter", - action: () => { - if (contextMenuRef.current?.node) { - contextMenuRef.current.onStartRename?.( - contextMenuRef.current.node.path - ); - contextMenuRef.current.onClose(); - } - }, - }), - MenuItem.new({ - text: translate("actions.delete", { defaultValue: "Delete" }), - accelerator: "CmdOrCtrl+Backspace", - action: async () => { - if (contextMenuRef.current?.node) { - const nodePath = contextMenuRef.current.node.path; - const nodeName = contextMenuRef.current.node.name; - const closeMenu = contextMenuRef.current.onClose; - const dispatchAction = contextMenuRef.current.dispatch; - closeMenu(); - const confirmed = await confirmDestructiveAction({ - title: translate("actions.confirmDelete", { - defaultValue: "Confirm Delete", - }), - message: `${translate("actions.delete", { - defaultValue: "Delete", - })} "${nodeName}"?`, - okLabel: translate("actions.delete", { - defaultValue: "Delete", - }), - }); - if (confirmed) { - await dispatchAction( - "file.delete", - { path: nodePath }, - "user" + const result = await popupNativeMenu({ + source: "file-explorer", + onBusy: onClose, + buildItems: () => { + const items: NativeMenuItemOptions[] = []; + const translate = i18next.t.bind(i18next); + items.push( + { + text: translate("actions.newFile", { + defaultValue: "New File", + }), + accelerator: "CmdOrCtrl+N", + action: () => { + if (contextMenuRef.current) { + const targetDirectory = getTargetDirectory( + contextMenuRef.current.node, + contextMenuRef.current.repoPath ); + const closeMenu = contextMenuRef.current.onClose; + const startCreateNew = + contextMenuRef.current.onStartCreateNew; + closeMenu(); + if (startCreateNew) { + requestAnimationFrame(() => + startCreateNew(targetDirectory, false) + ); + } } - } - }, - }), - MenuItem.new({ - text: translate("actions.duplicate", { - defaultValue: "Duplicate", - }), - accelerator: "CmdOrCtrl+D", - action: () => { - if (contextMenuRef.current?.node) { - contextMenuRef.current.dispatch( - "file.duplicate", - { path: contextMenuRef.current.node.path }, - "user" - ); - contextMenuRef.current.onClose(); - } - }, - }), - PredefinedMenuItem.new({ item: "Separator" }), - MenuItem.new({ - text: translate("actions.copy", { defaultValue: "Copy" }), - accelerator: "CmdOrCtrl+C", - action: () => { - if (contextMenuRef.current?.node) { - contextMenuRef.current.dispatch( - "file.copy", - { paths: [contextMenuRef.current.node.path] }, - "user" - ); - contextMenuRef.current.onClose(); - } - }, - }), - MenuItem.new({ - text: translate("actions.paste", { defaultValue: "Paste" }), - accelerator: "CmdOrCtrl+V", - enabled: hasPasteItems ?? false, - action: () => { - if (contextMenuRef.current?.node) { - const targetDirectory = getTargetDirectory( - contextMenuRef.current.node, - contextMenuRef.current.repoPath - ); - contextMenuRef.current.dispatch( - "file.paste", - { targetDir: targetDirectory }, - "user" - ); - contextMenuRef.current.onClose(); - } - }, - }), - PredefinedMenuItem.new({ item: "Separator" }), - MenuItem.new({ - text: translate("actions.copyPath", { - defaultValue: "Copy Path", - }), - action: () => { - if (contextMenuRef.current?.node) { - copyToClipboard(contextMenuRef.current.node.path); - contextMenuRef.current.onClose(); - } + }, }, - }), - MenuItem.new({ - text: translate("actions.copyRelativePath", { - defaultValue: "Copy Relative Path", - }), - action: () => { - if (contextMenuRef.current?.node) { - const relativePath = getRelativePath( - contextMenuRef.current.node.path, - contextMenuRef.current.repoPath - ); - copyToClipboard(relativePath); - contextMenuRef.current.onClose(); - } - }, - }), - PredefinedMenuItem.new({ item: "Separator" }), - MenuItem.new({ - text: translate(getFileManagerRevealLabelKey()), - action: () => { - if (contextMenuRef.current?.node) { - contextMenuRef.current.dispatch( - "file.revealInFinder", - { path: contextMenuRef.current.node.path }, - "user" - ); - contextMenuRef.current.onClose(); - } - }, - }), - ]); + { + text: translate("actions.newFolder", { + defaultValue: "New Folder", + }), + accelerator: "CmdOrCtrl+Shift+N", + action: () => { + if (contextMenuRef.current) { + const targetDirectory = getTargetDirectory( + contextMenuRef.current.node, + contextMenuRef.current.repoPath + ); + const closeMenu = contextMenuRef.current.onClose; + const startCreateNew = + contextMenuRef.current.onStartCreateNew; + closeMenu(); + if (startCreateNew) { + requestAnimationFrame(() => + startCreateNew(targetDirectory, true) + ); + } + } + }, + } + ); - items.push( - separatorBeforeNodeActions, - renameItem, - deleteItem, - duplicateItem, - separatorBeforeClipboardActions, - copyItem, - pasteItem, - separatorBeforePathActions, - copyPathItem, - copyRelativePathItem, - separatorBeforeRevealActions, - revealFinderItem - ); - } else { - const store = getInstrumentedStore(); - const clipboard = store.get(fileClipboardAtom); - const hasPasteItems = clipboard && clipboard.paths.length > 0; + const store = getInstrumentedStore(); + const clipboard = store.get(fileClipboardAtom); + const hasPasteItems = Boolean( + clipboard && clipboard.paths.length > 0 + ); - const backgroundItems = await Promise.all([ - ...(hasPasteItems - ? [ - PredefinedMenuItem.new({ item: "Separator" }), - MenuItem.new({ + if (node) { + items.push( + { item: "Separator" }, + { + text: translate("actions.rename", { defaultValue: "Rename" }), + accelerator: "Enter", + action: () => { + if (contextMenuRef.current?.node) { + contextMenuRef.current.onStartRename?.( + contextMenuRef.current.node.path + ); + contextMenuRef.current.onClose(); + } + }, + }, + { + text: translate("actions.delete", { defaultValue: "Delete" }), + accelerator: "CmdOrCtrl+Backspace", + action: async () => { + if (contextMenuRef.current?.node) { + const nodePath = contextMenuRef.current.node.path; + const nodeName = contextMenuRef.current.node.name; + const closeMenu = contextMenuRef.current.onClose; + const dispatchAction = contextMenuRef.current.dispatch; + closeMenu(); + const confirmed = await confirmDestructiveAction({ + title: translate("actions.confirmDelete", { + defaultValue: "Confirm Delete", + }), + message: `${translate("actions.delete", { + defaultValue: "Delete", + })} "${nodeName}"?`, + okLabel: translate("actions.delete", { + defaultValue: "Delete", + }), + }); + if (confirmed) { + await dispatchAction( + "file.delete", + { path: nodePath }, + "user" + ); + } + } + }, + }, + { + text: translate("actions.duplicate", { + defaultValue: "Duplicate", + }), + accelerator: "CmdOrCtrl+D", + action: () => { + if (contextMenuRef.current?.node) { + contextMenuRef.current.dispatch( + "file.duplicate", + { path: contextMenuRef.current.node.path }, + "user" + ); + contextMenuRef.current.onClose(); + } + }, + }, + { item: "Separator" }, + { + text: translate("actions.copy", { defaultValue: "Copy" }), + accelerator: "CmdOrCtrl+C", + action: () => { + if (contextMenuRef.current?.node) { + contextMenuRef.current.dispatch( + "file.copy", + { paths: [contextMenuRef.current.node.path] }, + "user" + ); + contextMenuRef.current.onClose(); + } + }, + }, + { + text: translate("actions.paste", { defaultValue: "Paste" }), + accelerator: "CmdOrCtrl+V", + enabled: hasPasteItems ?? false, + action: () => { + if (contextMenuRef.current?.node) { + const targetDirectory = getTargetDirectory( + contextMenuRef.current.node, + contextMenuRef.current.repoPath + ); + contextMenuRef.current.dispatch( + "file.paste", + { targetDir: targetDirectory }, + "user" + ); + contextMenuRef.current.onClose(); + } + }, + }, + { item: "Separator" }, + { + text: translate("actions.copyPath", { + defaultValue: "Copy Path", + }), + action: () => { + if (contextMenuRef.current?.node) { + copyToClipboard(contextMenuRef.current.node.path); + contextMenuRef.current.onClose(); + } + }, + }, + { + text: translate("actions.copyRelativePath", { + defaultValue: "Copy Relative Path", + }), + action: () => { + if (contextMenuRef.current?.node) { + const relativePath = getRelativePath( + contextMenuRef.current.node.path, + contextMenuRef.current.repoPath + ); + copyToClipboard(relativePath); + contextMenuRef.current.onClose(); + } + }, + }, + { item: "Separator" }, + { + text: translate(getFileManagerRevealLabelKey()), + action: () => { + if (contextMenuRef.current?.node) { + contextMenuRef.current.dispatch( + "file.revealInFinder", + { path: contextMenuRef.current.node.path }, + "user" + ); + contextMenuRef.current.onClose(); + } + }, + } + ); + } else { + if (hasPasteItems) { + items.push( + { item: "Separator" }, + { text: translate("actions.paste", { defaultValue: "Paste" }), accelerator: "CmdOrCtrl+V", action: () => { @@ -332,28 +303,37 @@ export function FileExplorerContextMenu(props: FileExplorerContextMenuProps) { contextMenuRef.current.onClose(); } }, + } + ); + } + items.push( + { item: "Separator" }, + { + text: translate("actions.refresh", { + defaultValue: "Refresh", }), - ] - : []), - PredefinedMenuItem.new({ item: "Separator" }), - MenuItem.new({ - text: translate("actions.refresh", { defaultValue: "Refresh" }), - action: () => { - if (contextMenuRef.current) { - contextMenuRef.current.dispatch("file.refresh", {}, "user"); - contextMenuRef.current.onClose(); + action: () => { + if (contextMenuRef.current) { + contextMenuRef.current.dispatch( + "file.refresh", + {}, + "user" + ); + contextMenuRef.current.onClose(); + } + }, } - }, - }), - ]); - items.push(...backgroundItems); - } + ); + } - const menu = await TauriMenu.new({ items }); - await menu.popup(); - setTimeout(() => { - onClose(); - }, 50); + return items; + }, + }); + if (result.status !== "busy") { + setTimeout(() => { + onClose(); + }, 50); + } } catch (error: unknown) { logger.error( "[FileExplorerContextMenu] Failed to show native context menu:", @@ -363,15 +343,7 @@ export function FileExplorerContextMenu(props: FileExplorerContextMenuProps) { } } - async function showNativeMenu(): Promise { - const result = await runNativeMenuSingleFlight( - "file-explorer", - showNativeMenuUnchecked - ); - if (result.status === "busy") onClose(); - } - - showNativeMenu(); + void showNativeMenu(); }, [node, repoPath, dispatch, onClose]); return null; diff --git a/src/components/WindowChrome/WindowsTopBar.tsx b/src/components/WindowChrome/WindowsTopBar.tsx index 405f7b3b94..b7fbab2516 100644 --- a/src/components/WindowChrome/WindowsTopBar.tsx +++ b/src/components/WindowChrome/WindowsTopBar.tsx @@ -1,9 +1,4 @@ import { LogicalPosition } from "@tauri-apps/api/dpi"; -import { - MenuItem, - PredefinedMenuItem, - Menu as TauriMenu, -} from "@tauri-apps/api/menu"; import { open } from "@tauri-apps/plugin-shell"; import type { TFunction } from "i18next"; import { Minus, Square, X } from "lucide-react"; @@ -16,7 +11,10 @@ import { maxWindow, minWindow, } from "@src/util/platform/ipcRenderer"; -import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; +import { + type NativeMenuItemOptions, + popupNativeMenu, +} from "@src/util/platform/tauri/nativeMenuPopup"; import { NoDragRegion } from "./NoDragRegion"; @@ -248,46 +246,27 @@ function getMenuItems(menu: NativeMenuKey, t: TFunction): NativeMenuItem[] { } } -async function showNativeStyleMenuUnchecked( - menuKey: NativeMenuKey, - anchor: HTMLElement, - t: TFunction -) { - const menuItems = await Promise.all( - getMenuItems(menuKey, t).map(async (item) => { - if (item.type === "separator") { - return PredefinedMenuItem.new({ item: "Separator" }); - } - - return MenuItem.new({ - text: item.text, - enabled: item.enabled ?? true, - accelerator: item.accelerator, - action: item.action, - }); - }) - ); - - const menu = await TauriMenu.new({ items: menuItems }); - const rect = anchor.getBoundingClientRect(); - - try { - await menu.popup( - new LogicalPosition(Math.round(rect.left), Math.round(rect.bottom)) - ); - } catch { - await menu.popup(); - } -} - async function showNativeStyleMenu( menuKey: NativeMenuKey, anchor: HTMLElement, t: TFunction ) { - await runNativeMenuSingleFlight(`windows-top-bar:${menuKey}`, () => - showNativeStyleMenuUnchecked(menuKey, anchor, t) - ); + const rect = anchor.getBoundingClientRect(); + await popupNativeMenu({ + source: `windows-top-bar:${menuKey}`, + buildItems: () => + getMenuItems(menuKey, t).map((item) => { + if (item.type === "separator") return { item: "Separator" }; + return { + text: item.text, + enabled: item.enabled ?? true, + accelerator: item.accelerator, + action: item.action, + }; + }), + at: new LogicalPosition(Math.round(rect.left), Math.round(rect.bottom)), + fallbackToCursor: true, + }); } const WindowsTopBarComponent: React.FC = () => { diff --git a/src/engines/ChatPanel/ChatPanelTabContextMenu.tsx b/src/engines/ChatPanel/ChatPanelTabContextMenu.tsx index 5b1762e542..3b0deb356b 100644 --- a/src/engines/ChatPanel/ChatPanelTabContextMenu.tsx +++ b/src/engines/ChatPanel/ChatPanelTabContextMenu.tsx @@ -1,10 +1,12 @@ -import { MenuItem, Menu as TauriMenu } from "@tauri-apps/api/menu"; import i18next from "i18next"; import { useEffect, useRef } from "react"; import { createLogger } from "@src/hooks/logger"; import type { SessionReferenceOpen } from "@src/shared/dnd/sessionTabDrag"; -import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; +import { + type NativeMenuItemOptions, + popupNativeMenu, +} from "@src/util/platform/tauri/nativeMenuPopup"; const logger = createLogger("ChatPanelTabContextMenu"); @@ -32,63 +34,59 @@ export function ChatPanelTabContextMenu( if (hasShownMenu.current) return; hasShownMenu.current = true; - async function showNativeMenuUnchecked(): Promise { + async function showNativeMenu(): Promise { try { - const translate = i18next.t.bind(i18next); - const [closeItem, closeOthersItem] = await Promise.all([ - MenuItem.new({ - text: translate("actions.close"), - action: () => { - const current = propsRef.current; - void current.onCloseTab(current.tabId); - current.onDismiss(); - }, - }), - MenuItem.new({ - text: translate("actions.closeOthers"), - action: () => { - const current = propsRef.current; - void current.onCloseOtherTabs(current.tabId); - current.onDismiss(); - }, - }), - ]); - const items: MenuItem[] = []; - const sessionReference = propsRef.current.sessionReference; - if (sessionReference) { - items.push( - await MenuItem.new({ - text: translate("teamInbox.handoff.createFromSession", { - defaultValue: "Create team Work Item…", - }), - action: () => { - const current = propsRef.current; - if (current.sessionReference) { - current.onCreateWorkItem?.(current.sessionReference); - } - current.onDismiss(); + const result = await popupNativeMenu({ + source: "chat-panel-tab", + onBusy: () => propsRef.current.onDismiss(), + buildItems: () => { + const translate = i18next.t.bind(i18next); + const items: NativeMenuItemOptions[] = []; + const sessionReference = propsRef.current.sessionReference; + if (sessionReference) { + items.push({ + text: translate("teamInbox.handoff.createFromSession", { + defaultValue: "Create team Work Item…", + }), + action: () => { + const current = propsRef.current; + if (current.sessionReference) { + current.onCreateWorkItem?.(current.sessionReference); + } + current.onDismiss(); + }, + }); + } + items.push( + { + text: translate("actions.close"), + action: () => { + const current = propsRef.current; + void current.onCloseTab(current.tabId); + current.onDismiss(); + }, }, - }) - ); + { + text: translate("actions.closeOthers"), + action: () => { + const current = propsRef.current; + void current.onCloseOtherTabs(current.tabId); + current.onDismiss(); + }, + } + ); + return items; + }, + }); + if (result.status !== "busy") { + setTimeout(() => propsRef.current.onDismiss(), 50); } - items.push(closeItem, closeOthersItem); - const menu = await TauriMenu.new({ items }); - await menu.popup(); - setTimeout(() => propsRef.current.onDismiss(), 50); } catch (error) { logger.error("Failed to show native context menu:", error); propsRef.current.onDismiss(); } } - async function showNativeMenu(): Promise { - const result = await runNativeMenuSingleFlight( - "chat-panel-tab", - showNativeMenuUnchecked - ); - if (result.status === "busy") propsRef.current.onDismiss(); - } - void showNativeMenu(); }, []); diff --git a/src/features/TaskKanban/hooks/useKanbanCardContextMenu.ts b/src/features/TaskKanban/hooks/useKanbanCardContextMenu.ts index 462b04684a..f24371b8b2 100644 --- a/src/features/TaskKanban/hooks/useKanbanCardContextMenu.ts +++ b/src/features/TaskKanban/hooks/useKanbanCardContextMenu.ts @@ -5,7 +5,6 @@ * a Tauri menu popped at the cursor, so a right-click on a card offers the two * open surfaces instead of the WebView's default Reload / Inspect menu. */ -import { MenuItem, Menu as TauriMenu } from "@tauri-apps/api/menu"; import { useSetAtom } from "jotai"; import { type MouseEvent, useCallback } from "react"; import { useTranslation } from "react-i18next"; @@ -14,7 +13,10 @@ import type { KanbanTask } from "@src/features/KanbanBoard"; import { createLogger } from "@src/hooks/logger"; import { openOrFocusSessionInChatPanelTabAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; import { activeStationChatVisibleAtom } from "@src/store/ui/chatPanelAtom"; -import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; +import { + type NativeMenuItemOptions, + popupNativeMenu, +} from "@src/util/platform/tauri/nativeMenuPopup"; import { KANBAN_CARD_CONTEXT_ACTION, @@ -63,29 +65,26 @@ export function useKanbanCardContextMenu({ }); if (actions.length === 0) return; - await runNativeMenuSingleFlight("kanban-card", async () => { - const items: MenuItem[] = []; - for (const action of actions) { - if (action === KANBAN_CARD_CONTEXT_ACTION.OpenFloatingPane) { - items.push( - await MenuItem.new({ + await popupNativeMenu({ + source: "kanban-card", + buildItems: () => { + const items: NativeMenuItemOptions[] = []; + for (const action of actions) { + if (action === KANBAN_CARD_CONTEXT_ACTION.OpenFloatingPane) { + items.push({ text: t("kanban.card.openAsFloatingPane"), action: () => onOpenFloatingPane(task), - }) - ); - continue; - } - if (!sessionId) continue; - items.push( - await MenuItem.new({ + }); + continue; + } + if (!sessionId) continue; + items.push({ text: tCommon("actions.openInNewTab"), action: () => openInNewTabPane(sessionId, task.title), - }) - ); - } - - const menu = await TauriMenu.new({ items }); - await menu.popup(); + }); + } + return items; + }, }); }, [onOpenFloatingPane, openInNewTabPane, remoteSessionsByTaskId, t, tCommon] diff --git a/src/hooks/ui/useResizeContextMenu.ts b/src/hooks/ui/useResizeContextMenu.ts index 1fe997d4e8..77217ae5dc 100644 --- a/src/hooks/ui/useResizeContextMenu.ts +++ b/src/hooks/ui/useResizeContextMenu.ts @@ -10,16 +10,14 @@ * - ResizableSplitPanel (Code Editor sidebar) * - EditorBottomPanel (bottom panel height) */ -import { - MenuItem, - PredefinedMenuItem, - Menu as TauriMenu, -} from "@tauri-apps/api/menu"; import i18next from "i18next"; import { type MouseEvent, useCallback } from "react"; import { createLogger } from "@src/hooks/logger"; -import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; +import { + type NativeMenuItemOptions, + popupNativeMenu, +} from "@src/util/platform/tauri/nativeMenuPopup"; const log = createLogger("useResizeContextMenu"); @@ -80,61 +78,56 @@ export function useResizeContextMenu({ const interpolateMin = dimension === "width" ? { width: minSize } : { height: minSize }; - void runNativeMenuSingleFlight("resize-handle", async () => { - try { - const resizeDefaultItem = await MenuItem.new({ - text: i18next.t(keys.resizeToDefault, interpolate), - enabled: !isAlreadyDefault, - action: () => { - onSizeChange(defaultSize); + void popupNativeMenu({ + source: "resize-handle", + buildItems: () => { + const items: NativeMenuItemOptions[] = [ + { + text: i18next.t(keys.resizeToDefault, interpolate), + enabled: !isAlreadyDefault, + action: () => { + onSizeChange(defaultSize); + }, }, - }); - const minimizeItem = await MenuItem.new({ - text: i18next.t(keys.minimize, interpolateMin), - enabled: !isAlreadyMin, - action: () => { - onSizeChange(minSize); + { + text: i18next.t(keys.minimize, interpolateMin), + enabled: !isAlreadyMin, + action: () => { + onSizeChange(minSize); + }, }, - }); - - const items: Array< - | Awaited> - | Awaited> - > = [resizeDefaultItem, minimizeItem]; + ]; if (positionAction) { - const positionSeparator = await PredefinedMenuItem.new({ - item: "Separator", - }); - const positionItem = await MenuItem.new({ - text: i18next.t( - positionAction.target === "left" - ? "spotlightActions.moveWorkstationSidebarLeft" - : "spotlightActions.moveWorkstationSidebarRight" - ), - action: positionAction.onSelect, - }); - items.push(positionSeparator, positionItem); + items.push( + { item: "Separator" }, + { + text: i18next.t( + positionAction.target === "left" + ? "spotlightActions.moveWorkstationSidebarLeft" + : "spotlightActions.moveWorkstationSidebarRight" + ), + action: positionAction.onSelect, + } + ); } if (onClose) { - const closeSeparator = await PredefinedMenuItem.new({ - item: "Separator", - }); - const closeItem = await MenuItem.new({ - text: i18next.t("tooltips.closePanel"), - action: () => { - onClose(); - }, - }); - items.push(closeSeparator, closeItem); + items.push( + { item: "Separator" }, + { + text: i18next.t("tooltips.closePanel"), + action: () => { + onClose(); + }, + } + ); } - const menu = await TauriMenu.new({ items }); - await menu.popup(); - } catch (error) { - log.error("Failed to show resize context menu:", error); - } + return items; + }, + }).catch((error) => { + log.error("Failed to show resize context menu:", error); }); }, [ diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/GitHistoryContent/GitHistoryContextMenu.tsx b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/GitHistoryContent/GitHistoryContextMenu.tsx index 411c7dd3ae..ca61bd5880 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/GitHistoryContent/GitHistoryContextMenu.tsx +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/GitHistoryContent/GitHistoryContextMenu.tsx @@ -1,8 +1,3 @@ -import { - MenuItem, - PredefinedMenuItem, - Menu as TauriMenu, -} from "@tauri-apps/api/menu"; import i18next from "i18next"; import { useEffect, useRef } from "react"; @@ -12,7 +7,10 @@ import { copyText } from "@src/util/data/clipboard"; import { confirmDestructiveAction } from "@src/util/dialogs/confirmDestructiveAction"; import { showGitActionDialogSafely } from "@src/util/dialogs/gitActionDialog"; import { openExternalLink } from "@src/util/platform/ipcRenderer"; -import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; +import { + type NativeMenuItemOptions, + popupNativeMenu, +} from "@src/util/platform/tauri/nativeMenuPopup"; const log = createLogger("GitHistoryContextMenu"); @@ -127,234 +125,204 @@ export default function GitHistoryContextMenu( onActionComplete, } = props; - async function showNativeMenuUnchecked() { + async function showNativeMenu() { try { - const t = i18next.t.bind(i18next); - - const [ - amendItem, - resetSoftItem, - resetMixedItem, - resetHardItem, - checkoutItem, - openInNewTabItem, - reorderItem, - sep1, - revertItem, - createBranchItem, - cherryPickItem, - sep2, - copyShaItem, - viewOnGitHubItem, - ] = await Promise.all([ - MenuItem.new({ - text: "Amend Commit...", - enabled: isHeadCommit, - action: async () => { - if (!isHeadCommit) return; - const confirmed = await confirmAction( - "Amend the latest commit using currently staged changes?", - "Confirm Amend Commit" - ); - if (!confirmed) return; - const result = (await dispatch( - "git.amend", - {}, - "user" - )) as ActionResult; - showResult(result, "Commit amended"); - onActionComplete(); - }, - }), - MenuItem.new({ - text: "Reset to Commit (Soft)", - action: async () => { - const confirmed = await confirmAction( - `Soft reset HEAD to ${commit.short_sha}?`, - "Confirm Soft Reset" - ); - if (!confirmed) return; - const result = (await dispatch( - "git.reset", - { ref: commit.sha, mode: "soft" }, - "user" - )) as ActionResult; - showResult(result, "Soft reset complete"); - onActionComplete(); - }, - }), - MenuItem.new({ - text: "Reset to Commit (Mixed)", - action: async () => { - const confirmed = await confirmAction( - `Mixed reset HEAD to ${commit.short_sha}?`, - "Confirm Mixed Reset" - ); - if (!confirmed) return; - const result = (await dispatch( - "git.reset", - { ref: commit.sha, mode: "mixed" }, - "user" - )) as ActionResult; - showResult(result, "Mixed reset complete"); - onActionComplete(); - }, - }), - MenuItem.new({ - text: "Reset to Commit (Hard)", - action: async () => { - const confirmed = await confirmAction( - `Hard reset HEAD to ${commit.short_sha}? This discards uncommitted changes.`, - "Confirm Hard Reset" - ); - if (!confirmed) return; - const result = (await dispatch( - "git.reset", - { ref: commit.sha, mode: "hard" }, - "user" - )) as ActionResult; - showResult(result, "Hard reset complete"); - onActionComplete(); - }, - }), - MenuItem.new({ - text: "Checkout Commit", - action: async () => { - const confirmed = await confirmAction( - `Checkout ${commit.short_sha}? This enters detached HEAD state.`, - "Confirm Checkout Commit" - ); - if (!confirmed) return; - const result = (await dispatch( - "git.checkout", - { branch: commit.sha, create: false }, - "user" - )) as ActionResult; - showResult(result, `Checked out ${commit.short_sha}`); - onActionComplete(); - }, - }), - MenuItem.new({ - text: t("common:actions.openInNewTab"), - action: () => { - props.onOpenInNewTab(commit); - }, - }), - MenuItem.new({ - text: "Reorder Commit", - enabled: false, - }), - PredefinedMenuItem.new({ item: "Separator" }), - MenuItem.new({ - text: "Revert Changes in Commit", - action: async () => { - const confirmed = await confirmAction( - `Revert changes introduced by ${commit.short_sha}?`, - "Confirm Revert Commit" - ); - if (!confirmed) return; - const result = (await dispatch( - "git.revertCommit", - { commitSha: commit.sha, noCommit: false }, - "user" - )) as ActionResult; - showResult(result, "Revert complete"); - onActionComplete(); - }, - }), - MenuItem.new({ - text: "Create Branch from Commit", - action: async () => { - const defaultName = `branch-${commit.short_sha}`; - const input = window.prompt( - "Enter new branch name:", - defaultName - ); - const branchName = input?.trim(); - if (!branchName) return; - const result = (await dispatch( - "git.createBranchFromCommit", - { - branchName, - commitSha: commit.sha, - checkout: true, + await popupNativeMenu({ + source: "git-history", + buildItems: () => { + const t = i18next.t.bind(i18next); + const items: NativeMenuItemOptions[] = [ + { + text: "Amend Commit...", + enabled: isHeadCommit, + action: async () => { + if (!isHeadCommit) return; + const confirmed = await confirmAction( + "Amend the latest commit using currently staged changes?", + "Confirm Amend Commit" + ); + if (!confirmed) return; + const result = (await dispatch( + "git.amend", + {}, + "user" + )) as ActionResult; + showResult(result, "Commit amended"); + onActionComplete(); }, - "user" - )) as ActionResult; - showResult(result, `Created branch ${branchName}`); - onActionComplete(); - }, - }), - MenuItem.new({ - text: "Cherry-pick Commit", - action: async () => { - const confirmed = await confirmAction( - `Cherry-pick ${commit.short_sha} onto current branch?`, - "Confirm Cherry-pick" - ); - if (!confirmed) return; - const result = (await dispatch( - "git.cherryPickCommit", - { commitSha: commit.sha, noCommit: false }, - "user" - )) as ActionResult; - showResult(result, "Cherry-pick complete"); - onActionComplete(); - }, - }), - PredefinedMenuItem.new({ item: "Separator" }), - MenuItem.new({ - text: t("common:git.commit.copySha"), - action: async () => { - await copyText(commit.sha); - showGitActionDialogSafely( - t("common:git.commit.shaCopied"), - "info" - ); - }, - }), - MenuItem.new({ - text: t("common:actions.viewOnGitHub"), - action: async () => { - const remotes = await getGitRemotes({ - repo_id: repoId, - repo_path: repoPath, - }); - const commitUrl = getGitHubCommitUrl( - remotes?.remotes ?? [], - commit.sha - ); - if (!commitUrl) { - showGitActionDialogSafely( - "No GitHub remote found for this repo", - "warning" - ); - return; - } - await openExternalLink(commitUrl); - }, - }), - ]); - - const menu = await TauriMenu.new({ - items: [ - amendItem, - resetSoftItem, - resetMixedItem, - resetHardItem, - checkoutItem, - openInNewTabItem, - reorderItem, - sep1, - revertItem, - createBranchItem, - cherryPickItem, - sep2, - copyShaItem, - viewOnGitHubItem, - ], + }, + { + text: "Reset to Commit (Soft)", + action: async () => { + const confirmed = await confirmAction( + `Soft reset HEAD to ${commit.short_sha}?`, + "Confirm Soft Reset" + ); + if (!confirmed) return; + const result = (await dispatch( + "git.reset", + { ref: commit.sha, mode: "soft" }, + "user" + )) as ActionResult; + showResult(result, "Soft reset complete"); + onActionComplete(); + }, + }, + { + text: "Reset to Commit (Mixed)", + action: async () => { + const confirmed = await confirmAction( + `Mixed reset HEAD to ${commit.short_sha}?`, + "Confirm Mixed Reset" + ); + if (!confirmed) return; + const result = (await dispatch( + "git.reset", + { ref: commit.sha, mode: "mixed" }, + "user" + )) as ActionResult; + showResult(result, "Mixed reset complete"); + onActionComplete(); + }, + }, + { + text: "Reset to Commit (Hard)", + action: async () => { + const confirmed = await confirmAction( + `Hard reset HEAD to ${commit.short_sha}? This discards uncommitted changes.`, + "Confirm Hard Reset" + ); + if (!confirmed) return; + const result = (await dispatch( + "git.reset", + { ref: commit.sha, mode: "hard" }, + "user" + )) as ActionResult; + showResult(result, "Hard reset complete"); + onActionComplete(); + }, + }, + { + text: "Checkout Commit", + action: async () => { + const confirmed = await confirmAction( + `Checkout ${commit.short_sha}? This enters detached HEAD state.`, + "Confirm Checkout Commit" + ); + if (!confirmed) return; + const result = (await dispatch( + "git.checkout", + { branch: commit.sha, create: false }, + "user" + )) as ActionResult; + showResult(result, `Checked out ${commit.short_sha}`); + onActionComplete(); + }, + }, + { + text: t("common:actions.openInNewTab"), + action: () => { + props.onOpenInNewTab(commit); + }, + }, + { + text: "Reorder Commit", + enabled: false, + }, + { item: "Separator" }, + { + text: "Revert Changes in Commit", + action: async () => { + const confirmed = await confirmAction( + `Revert changes introduced by ${commit.short_sha}?`, + "Confirm Revert Commit" + ); + if (!confirmed) return; + const result = (await dispatch( + "git.revertCommit", + { commitSha: commit.sha, noCommit: false }, + "user" + )) as ActionResult; + showResult(result, "Revert complete"); + onActionComplete(); + }, + }, + { + text: "Create Branch from Commit", + action: async () => { + const defaultName = `branch-${commit.short_sha}`; + const input = window.prompt( + "Enter new branch name:", + defaultName + ); + const branchName = input?.trim(); + if (!branchName) return; + const result = (await dispatch( + "git.createBranchFromCommit", + { + branchName, + commitSha: commit.sha, + checkout: true, + }, + "user" + )) as ActionResult; + showResult(result, `Created branch ${branchName}`); + onActionComplete(); + }, + }, + { + text: "Cherry-pick Commit", + action: async () => { + const confirmed = await confirmAction( + `Cherry-pick ${commit.short_sha} onto current branch?`, + "Confirm Cherry-pick" + ); + if (!confirmed) return; + const result = (await dispatch( + "git.cherryPickCommit", + { commitSha: commit.sha, noCommit: false }, + "user" + )) as ActionResult; + showResult(result, "Cherry-pick complete"); + onActionComplete(); + }, + }, + { item: "Separator" }, + { + text: t("common:git.commit.copySha"), + action: async () => { + await copyText(commit.sha); + showGitActionDialogSafely( + t("common:git.commit.shaCopied"), + "info" + ); + }, + }, + { + text: t("common:actions.viewOnGitHub"), + action: async () => { + const remotes = await getGitRemotes({ + repo_id: repoId, + repo_path: repoPath, + }); + const commitUrl = getGitHubCommitUrl( + remotes?.remotes ?? [], + commit.sha + ); + if (!commitUrl) { + showGitActionDialogSafely( + "No GitHub remote found for this repo", + "warning" + ); + return; + } + await openExternalLink(commitUrl); + }, + }, + ]; + return items; + }, }); - await menu.popup(); } catch (error) { log.error("[GitHistoryContextMenu] Failed to show menu:", error); } finally { @@ -362,15 +330,7 @@ export default function GitHistoryContextMenu( } } - async function showNativeMenu() { - const result = await runNativeMenuSingleFlight( - "git-history", - showNativeMenuUnchecked - ); - if (result.status === "busy") onClose(); - } - - showNativeMenu(); + void showNativeMenu(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/SourceControlContent/components/SourceControlContextMenu.tsx b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/SourceControlContent/components/SourceControlContextMenu.tsx index d0c48dd889..725de0ba8d 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/SourceControlContent/components/SourceControlContextMenu.tsx +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/SourceControlContent/components/SourceControlContextMenu.tsx @@ -7,11 +7,6 @@ * * Uses dispatch() for actions per GUI Action System guidelines. */ -import { - MenuItem, - PredefinedMenuItem, - Menu as TauriMenu, -} from "@tauri-apps/api/menu"; import i18next from "i18next"; import { useEffect, useRef } from "react"; @@ -19,7 +14,10 @@ import { createLogger } from "@src/hooks/logger"; import type { GitFile } from "@src/types/git/types"; import { copyText } from "@src/util/data/clipboard"; import { getFileManagerRevealLabelKey } from "@src/util/platform/fileManagerLabels"; -import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; +import { + type NativeMenuItemOptions, + popupNativeMenu, +} from "@src/util/platform/tauri/nativeMenuPopup"; import { GIT_LABELS } from "../config"; @@ -116,206 +114,193 @@ export default function SourceControlContextMenu( if (hasShownMenu.current) return; hasShownMenu.current = true; - async function showNativeMenuUnchecked() { + async function showNativeMenu() { try { - const ctx = contextMenuRef.current; - if (!ctx) { - onClose(); - return; - } - - const { file, repoPath: _repoPath, isConflictFile, isDirectory } = ctx; - const t = i18next.t.bind(i18next); - const files = ctx.files ?? [file]; - const labels = getSourceControlContextMenuActionLabels({ - isDirectory: !!isDirectory, - isStaged: file.staged, - changeCount: files.length, - }); - - const items: (MenuItem | PredefinedMenuItem)[] = []; - - if (!isDirectory) { - // --- Open Changes (diff view) --- - items.push( - await MenuItem.new({ - text: GIT_LABELS.openChanges, - action: () => { - const ref = contextMenuRef.current; - if (ref?.onSelect) { - ref.onSelect(ref.file.id); - } - }, - }) - ); - - // --- Open File --- - items.push( - await MenuItem.new({ - text: t("common:actions.openFile"), - action: () => { - const ref = contextMenuRef.current; - if (ref) { - const absPath = ref.repoPath - ? `${ref.repoPath}/${ref.file.path}` - : ref.file.path; - ref.dispatch( - "file.openAtLine", - { path: absPath, line: 1 }, - "user" - ); - } - }, - }) - ); - - // --- Separator --- - items.push(await PredefinedMenuItem.new({ item: "Separator" })); - } - - // --- Stage / Unstage --- - if (!isConflictFile) { - items.push( - await MenuItem.new({ - text: labels.stageToggle, + await popupNativeMenu({ + source: "source-control", + buildItems: () => { + const ctx = contextMenuRef.current; + if (!ctx) return []; + + const { file, isConflictFile, isDirectory } = ctx; + const t = i18next.t.bind(i18next); + const files = ctx.files ?? [file]; + const labels = getSourceControlContextMenuActionLabels({ + isDirectory: !!isDirectory, + isStaged: file.staged, + changeCount: files.length, + }); + + const items: NativeMenuItemOptions[] = []; + + if (!isDirectory) { + // --- Open Changes (diff view) --- + items.push({ + text: GIT_LABELS.openChanges, + action: () => { + const ref = contextMenuRef.current; + if (ref?.onSelect) { + ref.onSelect(ref.file.id); + } + }, + }); + + // --- Open File --- + items.push({ + text: t("common:actions.openFile"), + action: () => { + const ref = contextMenuRef.current; + if (ref) { + const absPath = ref.repoPath + ? `${ref.repoPath}/${ref.file.path}` + : ref.file.path; + ref.dispatch( + "file.openAtLine", + { path: absPath, line: 1 }, + "user" + ); + } + }, + }); + + // --- Separator --- + items.push({ item: "Separator" }); + } + + // --- Stage / Unstage --- + if (!isConflictFile) { + items.push({ + text: labels.stageToggle, + action: async () => { + const ref = contextMenuRef.current; + if (ref?.onStageToggle) { + const files = ref.files ?? [ref.file]; + await Promise.all( + files.map((file) => + ref.onStageToggle?.(file.id, !ref.file.staged) + ) + ); + } + }, + }); + } + + // --- Stage Resolved (conflict files) --- + if (isConflictFile) { + items.push({ + text: labels.markResolved, + action: async () => { + const ref = contextMenuRef.current; + if (ref?.onStageResolved) { + const files = ref.files ?? [ref.file]; + await Promise.all( + files.map((file) => ref.onStageResolved?.(file.id)) + ); + } + }, + }); + } + + // --- Discard Changes --- + items.push({ + text: labels.discard, action: async () => { const ref = contextMenuRef.current; - if (ref?.onStageToggle) { - const files = ref.files ?? [ref.file]; - await Promise.all( - files.map((file) => - ref.onStageToggle?.(file.id, !ref.file.staged) - ) - ); + if (ref?.onDiscardFiles && ref.files) { + await ref.onDiscardFiles(ref.files.map((file) => file.id)); + } else if (ref?.onDiscard) { + await ref.onDiscard(ref.file.id); } }, - }) - ); - } - - // --- Stage Resolved (conflict files) --- - if (isConflictFile) { - items.push( - await MenuItem.new({ - text: labels.markResolved, + }); + + // --- Conflict resolution options --- + if (isConflictFile) { + items.push({ item: "Separator" }); + + items.push({ + text: GIT_LABELS.acceptCurrentChange, + action: async () => { + const ref = contextMenuRef.current; + if (ref) { + const files = ref.files ?? [ref.file]; + await resolveConflictsForFiles(ref.dispatch, files, "ours"); + } + }, + }); + + items.push({ + text: GIT_LABELS.acceptIncomingChange, + action: async () => { + const ref = contextMenuRef.current; + if (ref) { + const files = ref.files ?? [ref.file]; + await resolveConflictsForFiles( + ref.dispatch, + files, + "theirs" + ); + } + }, + }); + } + + // --- Separator --- + items.push({ item: "Separator" }); + + // --- Copy Path --- + items.push({ + text: t("common:actions.copyPath"), + accelerator: "CmdOrCtrl+Alt+C", action: async () => { const ref = contextMenuRef.current; - if (ref?.onStageResolved) { - const files = ref.files ?? [ref.file]; - await Promise.all( - files.map((file) => ref.onStageResolved?.(file.id)) - ); + if (ref) { + const targetPath = ref.targetPath ?? ref.file.path; + const absPath = ref.repoPath + ? `${ref.repoPath}/${targetPath}` + : targetPath; + await copyText(absPath); } }, - }) - ); - } - - // --- Discard Changes --- - items.push( - await MenuItem.new({ - text: labels.discard, - action: async () => { - const ref = contextMenuRef.current; - if (ref?.onDiscardFiles && ref.files) { - await ref.onDiscardFiles(ref.files.map((file) => file.id)); - } else if (ref?.onDiscard) { - await ref.onDiscard(ref.file.id); - } - }, - }) - ); - - // --- Conflict resolution options --- - if (isConflictFile) { - items.push(await PredefinedMenuItem.new({ item: "Separator" })); - - items.push( - await MenuItem.new({ - text: GIT_LABELS.acceptCurrentChange, + }); + + // --- Copy Relative Path --- + items.push({ + text: t("common:actions.copyRelativePath"), + accelerator: "CmdOrCtrl+Shift+C", action: async () => { const ref = contextMenuRef.current; if (ref) { - const files = ref.files ?? [ref.file]; - await resolveConflictsForFiles(ref.dispatch, files, "ours"); + await copyText(ref.targetPath ?? ref.file.path); } }, - }) - ); + }); - items.push( - await MenuItem.new({ - text: GIT_LABELS.acceptIncomingChange, - action: async () => { + // --- Separator --- + items.push({ item: "Separator" }); + + // --- Reveal in OS file manager --- + items.push({ + text: t(getFileManagerRevealLabelKey()), + action: () => { const ref = contextMenuRef.current; if (ref) { - const files = ref.files ?? [ref.file]; - await resolveConflictsForFiles(ref.dispatch, files, "theirs"); + const targetPath = ref.targetPath ?? ref.file.path; + const absPath = ref.repoPath + ? `${ref.repoPath}/${targetPath}` + : targetPath; + ref.dispatch( + "file.revealInFinder", + { path: absPath }, + "user" + ); } }, - }) - ); - } - - // --- Separator --- - items.push(await PredefinedMenuItem.new({ item: "Separator" })); - - // --- Copy Path --- - items.push( - await MenuItem.new({ - text: t("common:actions.copyPath"), - accelerator: "CmdOrCtrl+Alt+C", - action: async () => { - const ref = contextMenuRef.current; - if (ref) { - const targetPath = ref.targetPath ?? ref.file.path; - const absPath = ref.repoPath - ? `${ref.repoPath}/${targetPath}` - : targetPath; - await copyText(absPath); - } - }, - }) - ); - - // --- Copy Relative Path --- - items.push( - await MenuItem.new({ - text: t("common:actions.copyRelativePath"), - accelerator: "CmdOrCtrl+Shift+C", - action: async () => { - const ref = contextMenuRef.current; - if (ref) { - await copyText(ref.targetPath ?? ref.file.path); - } - }, - }) - ); - - // --- Separator --- - items.push(await PredefinedMenuItem.new({ item: "Separator" })); - - // --- Reveal in OS file manager --- - items.push( - await MenuItem.new({ - text: t(getFileManagerRevealLabelKey()), - action: () => { - const ref = contextMenuRef.current; - if (ref) { - const targetPath = ref.targetPath ?? ref.file.path; - const absPath = ref.repoPath - ? `${ref.repoPath}/${targetPath}` - : targetPath; - ref.dispatch("file.revealInFinder", { path: absPath }, "user"); - } - }, - }) - ); - - // Build and show menu — popup() resolves when the menu closes - // (whether an item was selected or dismissed by clicking elsewhere) - const menu = await TauriMenu.new({ items }); - await menu.popup(); + }); + + return items; + }, + }); } catch (error) { log.error("[SourceControlContextMenu] Failed to show menu:", error); } finally { @@ -324,15 +309,7 @@ export default function SourceControlContextMenu( } } - async function showNativeMenu() { - const result = await runNativeMenuSingleFlight( - "source-control", - showNativeMenuUnchecked - ); - if (result.status === "busy") onClose(); - } - - showNativeMenu(); + void showNativeMenu(); }, [onClose]); // Native menu renders nothing in React diff --git a/src/modules/WorkStation/shared/TabBar/TabContextMenu.tsx b/src/modules/WorkStation/shared/TabBar/TabContextMenu.tsx index e40bca69c8..b436c6c0e7 100644 --- a/src/modules/WorkStation/shared/TabBar/TabContextMenu.tsx +++ b/src/modules/WorkStation/shared/TabBar/TabContextMenu.tsx @@ -7,18 +7,16 @@ * Uses dispatch() for actions per GUI Action System guidelines. * Matches the pattern used by TabManager.tsx which works reliably. */ -import { - MenuItem, - PredefinedMenuItem, - Menu as TauriMenu, -} from "@tauri-apps/api/menu"; import i18next from "i18next"; import { useEffect, useRef } from "react"; import { createLogger } from "@src/hooks/logger"; import { copyText } from "@src/util/data/clipboard"; import { getFileManagerRevealLabelKey } from "@src/util/platform/fileManagerLabels"; -import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; +import { + type NativeMenuItemOptions, + popupNativeMenu, +} from "@src/util/platform/tauri/nativeMenuPopup"; import type { WorkStationTab } from "./types"; @@ -158,182 +156,163 @@ export function TabContextMenu(props: TabContextMenuProps) { if (hasShownMenu.current) return; hasShownMenu.current = true; - async function showNativeMenuUnchecked() { + async function showNativeMenu() { try { - // Create menu items in parallel - each MenuItem.new() is an async IPC call - const t = i18next.t.bind(i18next); - - const [closeItem, closeOthersItem, closeSavedItem] = await Promise.all([ - MenuItem.new({ - text: t("actions.close"), - action: () => { - if (contextMenuRef.current) { - contextMenuRef.current.onCloseTab( - contextMenuRef.current.tab.id - ); - contextMenuRef.current.onClose(); - } - }, - }), - MenuItem.new({ - text: t("actions.closeOthers"), - action: () => { - if (contextMenuRef.current) { - contextMenuRef.current.onCloseOtherTabs( - contextMenuRef.current.tab.id + const result = await popupNativeMenu({ + source: "workstation-tab", + onBusy: onClose, + buildItems: () => { + const t = i18next.t.bind(i18next); + const items: NativeMenuItemOptions[] = [ + { + text: t("actions.close"), + action: () => { + if (contextMenuRef.current) { + contextMenuRef.current.onCloseTab( + contextMenuRef.current.tab.id + ); + contextMenuRef.current.onClose(); + } + }, + }, + { + text: t("actions.closeOthers"), + action: () => { + if (contextMenuRef.current) { + contextMenuRef.current.onCloseOtherTabs( + contextMenuRef.current.tab.id + ); + contextMenuRef.current.onClose(); + } + }, + }, + { + text: t("actions.closeSaved"), + action: () => { + if (contextMenuRef.current) { + contextMenuRef.current.onCloseSavedTabs(); + contextMenuRef.current.onClose(); + } + }, + }, + ]; + + if (tab.type === "chat-session") { + const sessionId = tab.data.sessionId; + if (typeof sessionId === "string" && sessionId.length > 0) { + items.push( + { item: "Separator" }, + { + text: t("teamInbox.handoff.createFromSession", { + defaultValue: "Create team Work Item…", + }), + action: () => { + const context = contextMenuRef.current; + if (context) { + context.onCreateWorkItemFromSession?.(context.tab); + } + context?.onClose(); + }, + }, + { + text: t("sessions:chat.moveToChatPanel", { + defaultValue: "Move to Chat Panel", + }), + action: () => { + const context = contextMenuRef.current; + if (context) + context.onMoveSessionToChatPanel?.(context.tab); + context?.onClose(); + }, + }, + { + text: t("sessions:chat.rawTranscript.menuItem", { + defaultValue: "View raw transcript", + }), + action: () => { + const context = contextMenuRef.current; + const activeSessionId = context?.tab.data.sessionId; + if (typeof activeSessionId === "string") { + context?.onViewRawTranscript?.(activeSessionId); + } + context?.onClose(); + }, + } ); - contextMenuRef.current.onClose(); - } - }, - }), - MenuItem.new({ - text: t("actions.closeSaved"), - action: () => { - if (contextMenuRef.current) { - contextMenuRef.current.onCloseSavedTabs(); - contextMenuRef.current.onClose(); } - }, - }), - ]); - - // Build menu items array - const items: (MenuItem | PredefinedMenuItem)[] = [ - closeItem, - closeOthersItem, - closeSavedItem, - ]; + } - if (tab.type === "chat-session") { - const sessionId = tab.data.sessionId; - if (typeof sessionId === "string" && sessionId.length > 0) { - const [separator, createWorkItem, moveItem, rawTranscriptItem] = - await Promise.all([ - PredefinedMenuItem.new({ item: "Separator" }), - MenuItem.new({ - text: t("teamInbox.handoff.createFromSession", { - defaultValue: "Create team Work Item…", - }), + if (filePath) { + items.push( + { item: "Separator" }, + { + text: t("actions.copyPath"), action: () => { - const context = contextMenuRef.current; - if (context) { - context.onCreateWorkItemFromSession?.(context.tab); + if (contextMenuRef.current?.tab) { + const path = getFilePath(contextMenuRef.current.tab); + if (path) copyToClipboard(path); } - context?.onClose(); + contextMenuRef.current?.onClose(); }, - }), - MenuItem.new({ - text: t("sessions:chat.moveToChatPanel", { - defaultValue: "Move to Chat Panel", - }), + }, + { + text: t("actions.copyRelativePath"), action: () => { - const context = contextMenuRef.current; - if (context) - context.onMoveSessionToChatPanel?.(context.tab); - context?.onClose(); + if (contextMenuRef.current?.tab) { + const path = getFilePath(contextMenuRef.current.tab); + const rel = path + ? getRelativePath(path, contextMenuRef.current.repoPath) + : null; + if (rel) copyToClipboard(rel); + } + contextMenuRef.current?.onClose(); }, - }), - MenuItem.new({ - text: t("sessions:chat.rawTranscript.menuItem", { - defaultValue: "View raw transcript", - }), + }, + { item: "Separator" }, + { + text: t(getFileManagerRevealLabelKey()), action: () => { - const context = contextMenuRef.current; - const activeSessionId = context?.tab.data.sessionId; - if (typeof activeSessionId === "string") { - context?.onViewRawTranscript?.(activeSessionId); + if (contextMenuRef.current?.tab) { + const path = getFilePath(contextMenuRef.current.tab); + if (path) { + revealInFileExplorer( + path, + contextMenuRef.current.dispatch + ); + } } - context?.onClose(); + contextMenuRef.current?.onClose(); + }, + }, + { + text: t("actions.revealInExplorer"), + action: () => { + if (contextMenuRef.current?.tab) { + const path = getFilePath(contextMenuRef.current.tab); + if (path && contextMenuRef.current.dispatch) { + contextMenuRef.current.dispatch( + "file.reveal", + { path }, + "user" + ); + } + } + contextMenuRef.current?.onClose(); }, - }), - ]); - items.push(separator, createWorkItem, moveItem, rawTranscriptItem); - } - } - - // Add file-related items if we have a file path (also in parallel) - if (filePath) { - const [ - separator1, - copyPathItem, - copyRelativePathItem, - separator2, - revealFinderItem, - revealExplorerItem, - ] = await Promise.all([ - PredefinedMenuItem.new({ item: "Separator" }), - MenuItem.new({ - text: t("actions.copyPath"), - action: () => { - if (contextMenuRef.current?.tab) { - const path = getFilePath(contextMenuRef.current.tab); - if (path) copyToClipboard(path); - } - contextMenuRef.current?.onClose(); - }, - }), - MenuItem.new({ - text: t("actions.copyRelativePath"), - action: () => { - if (contextMenuRef.current?.tab) { - const path = getFilePath(contextMenuRef.current.tab); - const rel = path - ? getRelativePath(path, contextMenuRef.current.repoPath) - : null; - if (rel) copyToClipboard(rel); - } - contextMenuRef.current?.onClose(); - }, - }), - PredefinedMenuItem.new({ item: "Separator" }), - MenuItem.new({ - text: t(getFileManagerRevealLabelKey()), - action: () => { - if (contextMenuRef.current?.tab) { - const path = getFilePath(contextMenuRef.current.tab); - if (path) { - revealInFileExplorer(path, contextMenuRef.current.dispatch); - } - } - contextMenuRef.current?.onClose(); - }, - }), - MenuItem.new({ - text: t("actions.revealInExplorer"), - action: () => { - if (contextMenuRef.current?.tab) { - const path = getFilePath(contextMenuRef.current.tab); - if (path && contextMenuRef.current.dispatch) { - contextMenuRef.current.dispatch( - "file.reveal", - { path }, - "user" - ); - } } - contextMenuRef.current?.onClose(); - }, - }), - ]); - - items.push( - separator1, - copyPathItem, - copyRelativePathItem, - separator2, - revealFinderItem, - revealExplorerItem - ); - } + ); + } - // Create and show the menu - const menu = await TauriMenu.new({ items }); - await menu.popup(); + return items; + }, + }); // After popup closes, ensure we clean up - setTimeout(() => { - onClose(); - }, 50); + if (result.status !== "busy") { + setTimeout(() => { + onClose(); + }, 50); + } } catch (error) { logger.error( "[TabContextMenu] Failed to show native context menu:", @@ -343,15 +322,7 @@ export function TabContextMenu(props: TabContextMenuProps) { } } - async function showNativeMenu() { - const result = await runNativeMenuSingleFlight( - "workstation-tab", - showNativeMenuUnchecked - ); - if (result.status === "busy") onClose(); - } - - showNativeMenu(); + void showNativeMenu(); }, [filePath, onClose, tab.data.sessionId, tab.type]); // Native menu doesn't render anything in React diff --git a/src/scaffold/GlobalSpotlight/components/SpotlightItemRow.tsx b/src/scaffold/GlobalSpotlight/components/SpotlightItemRow.tsx index ea4550af31..e1d2a4cf81 100644 --- a/src/scaffold/GlobalSpotlight/components/SpotlightItemRow.tsx +++ b/src/scaffold/GlobalSpotlight/components/SpotlightItemRow.tsx @@ -4,11 +4,6 @@ * Memoized row renderer for spotlight items. * Handles icons, labels, status indicators, git badges, and keyboard shortcuts. */ -import { - MenuItem, - PredefinedMenuItem, - Menu as TauriMenu, -} from "@tauri-apps/api/menu"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { Check, @@ -28,7 +23,10 @@ import Tooltip from "@src/components/Tooltip"; import { createLogger } from "@src/hooks/logger"; import { copyText } from "@src/util/data/clipboard"; import { getFileManagerRevealLabelKey } from "@src/util/platform/fileManagerLabels"; -import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; +import { + type NativeMenuItemOptions, + popupNativeMenu, +} from "@src/util/platform/tauri/nativeMenuPopup"; import { ICONS } from "../config"; import { SPOTLIGHT_TOKENS } from "../constants"; @@ -57,62 +55,53 @@ interface SpotlightContextMenuOptions { revealLabel: string; } -async function showSpotlightContextMenuUnchecked({ +async function showSpotlightContextMenu({ name, path, copyNameLabel, copyPathLabel, revealLabel, }: SpotlightContextMenuOptions): Promise { - const items: (MenuItem | PredefinedMenuItem)[] = []; - if (path) { - items.push( - await MenuItem.new({ - text: copyPathLabel, - action: () => { - void copyText(path).catch((error: unknown) => { - log.error("Failed to copy path:", error); - }); - }, - }) - ); - } - if (name) { - items.push( - await MenuItem.new({ - text: copyNameLabel, - action: () => { - void copyText(name).catch((error: unknown) => { - log.error("Failed to copy name:", error); - }); - }, - }) - ); - } - if (path) { - items.push( - await PredefinedMenuItem.new({ item: "Separator" }), - await MenuItem.new({ - text: revealLabel, - action: () => { - void revealItemInDir(path).catch((error: unknown) => { - log.error("Failed to reveal path in file manager:", error); - }); - }, - }) - ); - } - if (items.length === 0) return; - const menu = await TauriMenu.new({ items }); - await menu.popup(); -} - -async function showSpotlightContextMenu( - options: SpotlightContextMenuOptions -): Promise { - await runNativeMenuSingleFlight("global-spotlight", () => - showSpotlightContextMenuUnchecked(options) - ); + await popupNativeMenu({ + source: "global-spotlight", + buildItems: () => { + const items: NativeMenuItemOptions[] = []; + if (path) { + items.push({ + text: copyPathLabel, + action: () => { + void copyText(path).catch((error: unknown) => { + log.error("Failed to copy path:", error); + }); + }, + }); + } + if (name) { + items.push({ + text: copyNameLabel, + action: () => { + void copyText(name).catch((error: unknown) => { + log.error("Failed to copy name:", error); + }); + }, + }); + } + if (path) { + items.push( + { item: "Separator" }, + { + text: revealLabel, + action: () => { + void revealItemInDir(path).catch((error: unknown) => { + log.error("Failed to reveal path in file manager:", error); + }); + }, + } + ); + } + return items; + }, + }); } interface PathParts { diff --git a/src/scaffold/NavigationSidebar/SidebarBase.tsx b/src/scaffold/NavigationSidebar/SidebarBase.tsx index b08096ca0d..9e9e088113 100644 --- a/src/scaffold/NavigationSidebar/SidebarBase.tsx +++ b/src/scaffold/NavigationSidebar/SidebarBase.tsx @@ -14,11 +14,6 @@ * * ``` */ -import { - MenuItem, - PredefinedMenuItem, - Menu as TauriMenu, -} from "@tauri-apps/api/menu"; import i18next from "i18next"; import { useAtomValue, useSetAtom } from "jotai"; import { PanelLeft, Plus, X } from "lucide-react"; @@ -47,7 +42,7 @@ import { } from "@src/store/ui/sidebarAtom"; import { windowFullscreenAtom } from "@src/store/ui/uiAtom"; import { isTauriDesktop } from "@src/util/platform/tauri"; -import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; +import { popupNativeMenu } from "@src/util/platform/tauri/nativeMenuPopup"; import { SIDEBAR_STYLE } from "./config"; import { useForceVisibleSidebar } from "./contexts/ForceVisibleContext"; @@ -169,44 +164,40 @@ const SidebarBase: React.FC = React.memo( const isAlreadyDefault = sidebarWidth === DEFAULT_SIDEBAR_WIDTH; const isAlreadyMin = sidebarWidth <= MIN_SIDEBAR_WIDTH; - void runNativeMenuSingleFlight("navigation-sidebar", async () => { - try { + void popupNativeMenu({ + source: "navigation-sidebar", + buildItems: () => { const t = i18next.t.bind(i18next); - - const resizeDefaultItem = await MenuItem.new({ - text: t("tooltips.resizeToDefault", { - width: DEFAULT_SIDEBAR_WIDTH, - }), - enabled: !isAlreadyDefault, - action: () => { - setWidth(DEFAULT_SIDEBAR_WIDTH); + return [ + { + text: t("tooltips.resizeToDefault", { + width: DEFAULT_SIDEBAR_WIDTH, + }), + enabled: !isAlreadyDefault, + action: () => { + setWidth(DEFAULT_SIDEBAR_WIDTH); + }, }, - }); - const minimizeItem = await MenuItem.new({ - text: t("tooltips.minimizeWidth", { - width: MIN_SIDEBAR_WIDTH, - }), - enabled: !isAlreadyMin, - action: () => { - setWidth(MIN_SIDEBAR_WIDTH); + { + text: t("tooltips.minimizeWidth", { + width: MIN_SIDEBAR_WIDTH, + }), + enabled: !isAlreadyMin, + action: () => { + setWidth(MIN_SIDEBAR_WIDTH); + }, }, - }); - const separator = await PredefinedMenuItem.new({ - item: "Separator", - }); - const hideItem = await MenuItem.new({ - text: t("tooltips.hideSidebar"), - action: () => { - collapse(); + { item: "Separator" as const }, + { + text: t("tooltips.hideSidebar"), + action: () => { + collapse(); + }, }, - }); - const menu = await TauriMenu.new({ - items: [resizeDefaultItem, minimizeItem, separator, hideItem], - }); - await menu.popup(); - } catch (error) { - log.error("Failed to show sidebar context menu:", error); - } + ]; + }, + }).catch((error) => { + log.error("Failed to show sidebar context menu:", error); }); }, [sidebarWidth, setWidth, collapse] diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/channelsSection.orgSwitch.test.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/channelsSection.orgSwitch.test.ts index b238aa834f..a494dd75f7 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/channelsSection.orgSwitch.test.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/channelsSection.orgSwitch.test.ts @@ -32,7 +32,6 @@ const mocks = vi.hoisted(() => ({ listCloudChannels: vi.fn(), getCloudCapabilities: vi.fn(), loadCloudOrgMembers: vi.fn(), - menuItemNew: vi.fn(), menuNew: vi.fn(), })); @@ -41,7 +40,6 @@ vi.mock("react-i18next", () => ({ })); vi.mock("@tauri-apps/api/menu", () => ({ - MenuItem: { new: mocks.menuItemNew }, Menu: { new: mocks.menuNew }, })); @@ -144,8 +142,7 @@ describe("useCloudChannelsSection create-dialog org keying", () => { vi.clearAllMocks(); localStorage.clear(); mocks.getCloudCapabilities.mockResolvedValue({ orgChannels: true }); - mocks.menuItemNew.mockImplementation(async (entry) => entry); - mocks.menuNew.mockResolvedValue({ popup: vi.fn() }); + mocks.menuNew.mockResolvedValue({ popup: vi.fn(), close: vi.fn() }); mocks.listCloudChannels.mockResolvedValue({ channels: [], serverTime: undefined, @@ -263,8 +260,15 @@ describe("useCloudChannelsSection create-dialog org keying", () => { ?.click(); }); await flushAsync(); - const settingsEntry = mocks.menuItemNew.mock.calls - .map(([entry]) => entry as { text?: string; action?: () => void }) + const settingsEntry = mocks.menuNew.mock.calls + .flatMap( + ([options]) => + ( + options as { + items?: Array<{ text?: string; action?: () => void }>; + } + ).items ?? [] + ) .find((entry) => entry.text === "cloud.channels.settings.action"); expect(settingsEntry?.action).toBeTypeOf("function"); act(() => settingsEntry?.action?.()); diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/channelsSection.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/channelsSection.tsx index 2da79b893b..00d6ba6511 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/channelsSection.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/channelsSection.tsx @@ -23,7 +23,6 @@ * lives there, which keeps the `ORG2_LAST_MANAGER` refusal handling in one * place instead of duplicating a remove-self RPC flow here. */ -import { MenuItem, Menu as TauriMenu } from "@tauri-apps/api/menu"; import { useAtomValue, useSetAtom } from "jotai"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -49,7 +48,7 @@ import { openChannelInChatPanelTabAtom, reconcileDiscussionChannelTabsAtom, } from "@src/store/chatPanel/chatPanelTabsAtom"; -import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; +import { popupNativeMenu } from "@src/util/platform/tauri/nativeMenuPopup"; import { CLOUD_CHANNELS_EMPTY_ID, @@ -245,14 +244,12 @@ export function useCloudChannelsSection({ }, ] : []; - void runNativeMenuSingleFlight("cloud-channel-row", async () => { - const menuItems = await Promise.all( - [...settingsEntries, ...kinds.map((kind) => entries[kind])].map( - (entry) => MenuItem.new(entry) - ) - ); - const menu = await TauriMenu.new({ items: menuItems }); - await menu.popup(); + void popupNativeMenu({ + source: "cloud-channel-row", + buildItems: () => [ + ...settingsEntries, + ...kinds.map((kind) => entries[kind]), + ], }).catch((error) => { log.warn("channel row menu failed to open:", error); }); diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.rowItemBuilder.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.rowItemBuilder.tsx index 1e819cc768..680c0fde0b 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.rowItemBuilder.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.rowItemBuilder.tsx @@ -5,11 +5,6 @@ * overflow menu with copy-id/remove). Split out because it is the single * largest piece of that section's row-construction logic. */ -import { - MenuItem, - PredefinedMenuItem, - Menu as TauriMenu, -} from "@tauri-apps/api/menu"; import type { TFunction } from "i18next"; import { GitFork, Loader2, MoreHorizontal, Pin, PinOff } from "lucide-react"; import { useCallback } from "react"; @@ -35,7 +30,7 @@ import { useCloudSessionDownloadProgressEntry } from "@src/features/Org2Cloud/us import type { NavigationMenuItem } from "@src/scaffold/NavigationSidebar/components/NavigationMenu/config"; import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; import { copyText } from "@src/util/data/clipboard"; -import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; +import { popupNativeMenu } from "@src/util/platform/tauri/nativeMenuPopup"; import { resolveSessionDisplayMetadata } from "@src/util/session/sessionDisplayMetadata"; import { formatRelativeTime } from "@src/util/time/formatRelativeTime"; @@ -278,41 +273,35 @@ export function useCloudSessionRowItemBuilder({ icon: MoreHorizontal, label: tCommon("actions.more"), onClick: () => { - void runNativeMenuSingleFlight("cloud-session-row", async () => { - const [copyItem, pinItem, menuSeparator, removeItem] = - await Promise.all([ - MenuItem.new({ - text: t("cloud.sidebar.copyId"), - action: () => { - void copyText(buildCloudSessionReference(row)) - .then(() => { - Message.success( - tCommon("actions.copied", "Copied") - ); - }) - .catch(() => { - Message.error( - tCommon("actions.copyFailed", "Copy failed") - ); - }); - }, - }), - MenuItem.new({ - text: isPinned - ? tCommon("sessions:chat.unpinSession", "Unpin") - : tCommon("sessions:chat.pinSession", "Pin"), - action: () => toggleRemoteSessionPin(row.orgId, row.id), - }), - PredefinedMenuItem.new({ item: "Separator" }), - MenuItem.new({ - text: tCommon("actions.remove", "Remove"), - action: () => hideRemoteSession(row), - }), - ]); - const menu = await TauriMenu.new({ - items: [copyItem, pinItem, menuSeparator, removeItem], - }); - await menu.popup(); + void popupNativeMenu({ + source: "cloud-session-row", + buildItems: () => [ + { + text: t("cloud.sidebar.copyId"), + action: () => { + void copyText(buildCloudSessionReference(row)) + .then(() => { + Message.success(tCommon("actions.copied", "Copied")); + }) + .catch(() => { + Message.error( + tCommon("actions.copyFailed", "Copy failed") + ); + }); + }, + }, + { + text: isPinned + ? tCommon("sessions:chat.unpinSession", "Unpin") + : tCommon("sessions:chat.pinSession", "Pin"), + action: () => toggleRemoteSessionPin(row.orgId, row.id), + }, + { item: "Separator" as const }, + { + text: tCommon("actions.remove", "Remove"), + action: () => hideRemoteSession(row), + }, + ], }); }, }, diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/localChannelsSection.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/localChannelsSection.tsx index 2a43dd461a..7f7c780fb9 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/localChannelsSection.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/localChannelsSection.tsx @@ -14,7 +14,6 @@ * channel"). No role gating — a local channel's single user can always open * settings, archive, and delete. */ -import { MenuItem, Menu as TauriMenu } from "@tauri-apps/api/menu"; import { useAtomValue, useSetAtom } from "jotai"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -39,7 +38,7 @@ import { reconcileLocalChannelMessagesAtom, unarchiveLocalChannelAtom, } from "@src/store/ui/localChannelsAtom"; -import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; +import { popupNativeMenu } from "@src/util/platform/tauri/nativeMenuPopup"; import { LOCAL_CHANNELS_EMPTY_ID, @@ -182,12 +181,9 @@ export function useLocalChannelsSection({ action: () => setDialogState({ kind: "delete", channel }), }, ]; - void runNativeMenuSingleFlight("local-channel-row", async () => { - const menuItems = await Promise.all( - entries.map((entry) => MenuItem.new(entry)) - ); - const menu = await TauriMenu.new({ items: menuItems }); - await menu.popup(); + void popupNativeMenu({ + source: "local-channel-row", + buildItems: () => entries, }).catch((error) => { log.warn("local channel row menu failed to open:", error); }); diff --git a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarContextMenu.ts b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarContextMenu.ts index e5f04efcd2..5ddd9d4179 100644 --- a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarContextMenu.ts +++ b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarContextMenu.ts @@ -1,14 +1,12 @@ -import { - MenuItem, - PredefinedMenuItem, - Menu as TauriMenu, -} from "@tauri-apps/api/menu"; import { type MouseEvent, useCallback } from "react"; import { createLogger } from "@src/hooks/logger"; import type { NavigationMenuItem } from "@src/scaffold/NavigationSidebar/components/NavigationMenu/config"; import type { Session } from "@src/store/session"; -import { runNativeMenuSingleFlight } from "@src/util/platform/tauri/nativeMenuSingleFlight"; +import { + type NativeMenuItemOptions, + popupNativeMenu, +} from "@src/util/platform/tauri/nativeMenuPopup"; import { isCursorIdeSession, isHumanSession, @@ -86,146 +84,113 @@ export function useWorkstationSidebarContextMenu({ _key: string, item: NavigationMenuItem ) => Promise { - const showMenuUnchecked = useCallback( - async (_event: MouseEvent, _key: string, item: NavigationMenuItem) => { + const buildMenuItems = useCallback( + (_key: string, item: NavigationMenuItem): NativeMenuItemOptions[] => { if (isDraftMenuItemId(item.id)) { const draftId = getDraftIdFromMenuItemId(item.id); - if (!draftId) return; - const removeDraftItem = await MenuItem.new({ - text: tCommon("sessions:sidebar.removeDraft", "Remove draft"), - action: () => handleDeleteDraft(draftId), - }); - const menu = await TauriMenu.new({ items: [removeDraftItem] }); - await menu.popup(); - return; + if (!draftId) return []; + return [ + { + text: tCommon("sessions:sidebar.removeDraft", "Remove draft"), + action: () => handleDeleteDraft(draftId), + }, + ]; } if (!sessionMap.has(item.id)) { - if (!handleCloudRemoteItemRemove) return; - const removeItem = await MenuItem.new({ - text: tCommon("actions.remove", "Remove"), - action: () => handleCloudRemoteItemRemove(item), - }); - const menu = await TauriMenu.new({ items: [removeItem] }); - await menu.popup(); - return; + if (!handleCloudRemoteItemRemove) return []; + return [ + { + text: tCommon("actions.remove", "Remove"), + action: () => handleCloudRemoteItemRemove(item), + }, + ]; } const isCursorIde = isCursorIdeSession(item.id); const session = sessionMap.get(item.id); // Subagent rows have no meaningful row-level actions. - if (session?.parentSessionId || item.id.includes(":subagent:")) return; + if (session?.parentSessionId || item.id.includes(":subagent:")) return []; - try { - const openInNewTabItem = await MenuItem.new({ - text: tCommon("actions.openInNewTab", "Open in New Tab"), - action: () => handleOpenInNewTab(item.id), - }); - const openInMyStationItem = await MenuItem.new({ - text: tCommon( - "sessions:controlTower.sidebar.openInMyStation", - "Open in My Station" - ), - action: () => handleOpenInMyStation(item.id), - }); - const pinLabel = session?.pinned + const openInNewTabItem: NativeMenuItemOptions = { + text: tCommon("actions.openInNewTab", "Open in New Tab"), + action: () => handleOpenInNewTab(item.id), + }; + const openInMyStationItem: NativeMenuItemOptions = { + text: tCommon( + "sessions:controlTower.sidebar.openInMyStation", + "Open in My Station" + ), + action: () => handleOpenInMyStation(item.id), + }; + const pinItem: NativeMenuItemOptions = { + text: session?.pinned ? tCommon("sessions:chat.unpinSession", "Unpin") - : tCommon("sessions:chat.pinSession", "Pin"); - const pinItem = await MenuItem.new({ - text: pinLabel, - action: () => handleTogglePin(item.id), - }); + : tCommon("sessions:chat.pinSession", "Pin"), + action: () => handleTogglePin(item.id), + }; - if (isCursorIde) { - const menu = await TauriMenu.new({ - items: [openInNewTabItem, openInMyStationItem, pinItem], - }); - await menu.popup(); - return; - } + if (isCursorIde) { + return [openInNewTabItem, openInMyStationItem, pinItem]; + } - if (isChatPanelTuiSessionId(item.id)) { - const deleteItem = await MenuItem.new({ - text: tCommon("actions.delete"), - action: () => handleDeleteSession(item.id), - }); - const menu = await TauriMenu.new({ - items: [openInNewTabItem, pinItem, deleteItem], - }); - await menu.popup(); - return; - } + const deleteItem: NativeMenuItemOptions = { + text: tCommon("actions.delete"), + action: () => handleDeleteSession(item.id), + }; + if (isChatPanelTuiSessionId(item.id)) { + return [openInNewTabItem, pinItem, deleteItem]; + } - const renameItem = await MenuItem.new({ + const primaryItems: NativeMenuItemOptions[] = [ + openInNewTabItem, + openInMyStationItem, + { text: tCommon("actions.rename"), action: () => rename.open(item.id, sessionMap), - }); - const exportItem = await MenuItem.new({ + }, + ]; + if (!isHumanSession(item.id)) { + primaryItems.push({ text: tCommon("sessions:chat.exportAsMarkdown", "Export as Markdown"), action: () => handleExportMarkdown(item.id), }); - const deleteItem = await MenuItem.new({ - text: tCommon("actions.delete"), - action: () => handleDeleteSession(item.id), + } + // Move (tag) the session into a managed cloud org, independent of + // repo-scope auto-sharing. Owner's own pushable sessions only. + if (session && isMoveEligible(session)) { + primaryItems.push({ + text: moveToOrgLabel, + action: () => handleOpenMoveToOrg(session), }); - const menuSeparator = await PredefinedMenuItem.new({ - item: "Separator", + } + // Per-session cloud access ladder (§13.4): Off / Metadata only / + // Full replay + org/restricted visibility, per cloud org. + if (session && isCloudSyncLevelEligible(session)) { + primaryItems.push({ + text: cloudSyncLevelLabel, + action: () => handleOpenCloudSyncLevel(session), }); - const primaryItems = [ - openInNewTabItem, - openInMyStationItem, - renameItem, - ...(!isHumanSession(item.id) ? [exportItem] : []), - ]; - // Move (tag) the session into a managed cloud org, independent of - // repo-scope auto-sharing. Owner's own pushable sessions only. - if (session && isMoveEligible(session)) { - primaryItems.push( - await MenuItem.new({ - text: moveToOrgLabel, - action: () => handleOpenMoveToOrg(session), - }) - ); - } - // Per-session cloud access ladder (§13.4): Off / Metadata only / - // Full replay + org/restricted visibility, per cloud org. - if (session && isCloudSyncLevelEligible(session)) { - primaryItems.push( - await MenuItem.new({ - text: cloudSyncLevelLabel, - action: () => handleOpenCloudSyncLevel(session), - }) - ); - } - // Cloud per-session shares (0012): directed member grants + guest - // link shares, for the owner's own cloud-synced sessions. - if (session && isCloudShareEligible(session)) { - primaryItems.push( - await MenuItem.new({ - text: cloudShareLabel, - action: () => handleOpenCloudShare(session), - }) - ); - } - // Non-secret reference for issue trackers and PRs. Sits beside the - // sharing actions because it is only meaningful once shared. - if (session && isCopyReferenceEligible(session)) { - primaryItems.push( - await MenuItem.new({ - text: copyReferenceLabel, - action: () => handleCopyReference(session), - }) - ); - } - primaryItems.push(pinItem); - const menu = await TauriMenu.new({ - items: [...primaryItems, menuSeparator, deleteItem], + } + // Cloud per-session shares (0012): directed member grants + guest + // link shares, for the owner's own cloud-synced sessions. + if (session && isCloudShareEligible(session)) { + primaryItems.push({ + text: cloudShareLabel, + action: () => handleOpenCloudShare(session), + }); + } + // Non-secret reference for issue trackers and PRs. Sits beside the + // sharing actions because it is only meaningful once shared. + if (session && isCopyReferenceEligible(session)) { + primaryItems.push({ + text: copyReferenceLabel, + action: () => handleCopyReference(session), }); - await menu.popup(); - } catch (error) { - log.error("[WorkstationSidebar] Context menu failed:", error); } + + return [...primaryItems, pinItem, { item: "Separator" }, deleteItem]; }, [ sessionMap, @@ -257,10 +222,15 @@ export function useWorkstationSidebarContextMenu({ async (event: MouseEvent, key: string, item: NavigationMenuItem) => { event.preventDefault(); event.stopPropagation(); - await runNativeMenuSingleFlight("workstation-sidebar-row", () => - showMenuUnchecked(event, key, item) - ); + try { + await popupNativeMenu({ + source: "workstation-sidebar-row", + buildItems: () => buildMenuItems(key, item), + }); + } catch (error) { + log.error("[WorkstationSidebar] Context menu failed:", error); + } }, - [showMenuUnchecked] + [buildMenuItems] ); } diff --git a/src/util/platform/tauri/nativeMenuPopup.test.ts b/src/util/platform/tauri/nativeMenuPopup.test.ts new file mode 100644 index 0000000000..67603bcfb5 --- /dev/null +++ b/src/util/platform/tauri/nativeMenuPopup.test.ts @@ -0,0 +1,269 @@ +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { popupNativeMenu } from "./nativeMenuPopup"; + +const tauriMenu = vi.hoisted(() => ({ + close: vi.fn<() => Promise>(), + create: vi.fn(), + popup: vi.fn<(...args: unknown[]) => Promise>(), +})); + +vi.mock("@tauri-apps/api/menu", () => ({ + Menu: { + new: tauriMenu.create, + }, +})); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function listSourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return listSourceFiles(entryPath); + return /\.tsx?$/.test(entry.name) ? [entryPath] : []; + }); +} + +describe("popupNativeMenu", () => { + beforeEach(() => { + tauriMenu.close.mockReset().mockResolvedValue(undefined); + tauriMenu.popup.mockReset().mockResolvedValue(undefined); + tauriMenu.create.mockReset().mockResolvedValue({ + close: tauriMenu.close, + popup: tauriMenu.popup, + }); + }); + + afterEach(() => { + vi.resetModules(); + }); + + it("drops a concurrent request before building or creating its menu", async () => { + const firstBuild = deferred<[{ text: string }]>(); + const duplicateBuild = vi.fn(() => [{ text: "Duplicate" }]); + const onBusy = vi.fn(); + + const firstResultPromise = popupNativeMenu({ + source: "file-explorer", + buildItems: () => firstBuild.promise, + }); + const duplicateResult = await popupNativeMenu({ + source: "tab-context-menu", + buildItems: duplicateBuild, + onBusy, + }); + + expect(duplicateBuild).not.toHaveBeenCalled(); + expect(tauriMenu.create).not.toHaveBeenCalled(); + expect(onBusy).toHaveBeenCalledWith("file-explorer"); + expect(duplicateResult).toEqual({ + status: "busy", + activeSource: "file-explorer", + }); + + firstBuild.resolve([{ text: "First" }]); + await expect(firstResultPromise).resolves.toEqual({ status: "closed" }); + }); + + it("creates one menu from plain options and closes it after popup", async () => { + const items = [{ text: "Open", action: vi.fn() }]; + + await expect( + popupNativeMenu({ source: "main", buildItems: () => items }) + ).resolves.toEqual({ status: "closed" }); + + expect(tauriMenu.create).toHaveBeenCalledOnce(); + expect(tauriMenu.create).toHaveBeenCalledWith({ items }); + expect(tauriMenu.popup).toHaveBeenCalledOnce(); + expect(tauriMenu.close).toHaveBeenCalledOnce(); + expect(tauriMenu.popup.mock.invocationCallOrder[0]).toBeLessThan( + tauriMenu.close.mock.invocationCallOrder[0] + ); + }); + + it("does not allocate a menu when the builder returns no items", async () => { + await expect( + popupNativeMenu({ source: "empty", buildItems: () => [] }) + ).resolves.toEqual({ status: "empty" }); + + expect(tauriMenu.create).not.toHaveBeenCalled(); + }); + + it("releases the gate when building or menu creation rejects", async () => { + const buildFailure = new Error("build failed"); + await expect( + popupNativeMenu({ + source: "build-failure", + buildItems: () => { + throw buildFailure; + }, + }) + ).rejects.toBe(buildFailure); + + const createFailure = new Error("create failed"); + tauriMenu.create.mockRejectedValueOnce(createFailure); + await expect( + popupNativeMenu({ + source: "create-failure", + buildItems: () => [{ text: "Create" }], + }) + ).rejects.toBe(createFailure); + + await expect( + popupNativeMenu({ + source: "post-create-recovery", + buildItems: () => [{ text: "Recovered" }], + }) + ).resolves.toEqual({ status: "closed" }); + }); + + it("closes the menu and releases the gate when popup rejects", async () => { + const failure = new Error("popup failed"); + tauriMenu.popup.mockRejectedValueOnce(failure); + + await expect( + popupNativeMenu({ + source: "broken-menu", + buildItems: () => [{ text: "Broken" }], + }) + ).rejects.toBe(failure); + expect(tauriMenu.close).toHaveBeenCalledOnce(); + + await expect( + popupNativeMenu({ + source: "recovery-menu", + buildItems: () => [{ text: "Recovered" }], + }) + ).resolves.toEqual({ status: "closed" }); + }); + + it("releases the gate when resource cleanup rejects", async () => { + const closeFailure = new Error("close failed"); + tauriMenu.close.mockRejectedValueOnce(closeFailure); + + await expect( + popupNativeMenu({ + source: "close-failure", + buildItems: () => [{ text: "Close" }], + }) + ).rejects.toBe(closeFailure); + + await expect( + popupNativeMenu({ + source: "post-close-recovery", + buildItems: () => [{ text: "Recovered" }], + }) + ).resolves.toEqual({ status: "closed" }); + }); + + it("preserves popup and cleanup failures together", async () => { + const popupFailure = new Error("popup failed"); + const closeFailure = new Error("close failed"); + tauriMenu.popup.mockRejectedValueOnce(popupFailure); + tauriMenu.close.mockRejectedValueOnce(closeFailure); + + await expect( + popupNativeMenu({ + source: "double-failure", + buildItems: () => [{ text: "Broken" }], + }) + ).rejects.toMatchObject({ + errors: [popupFailure, closeFailure], + message: "Native menu popup and cleanup both failed", + }); + }); + + it("uses cursor fallback only when explicitly requested", async () => { + const positionedFailure = new Error("position unsupported"); + const position = { type: "Logical", x: 10, y: 20 } as never; + tauriMenu.popup.mockRejectedValueOnce(positionedFailure); + + await expect( + popupNativeMenu({ + source: "windows-menu", + buildItems: () => [{ text: "File" }], + at: position, + fallbackToCursor: true, + }) + ).resolves.toEqual({ status: "closed" }); + + expect(tauriMenu.popup).toHaveBeenNthCalledWith(1, position); + expect(tauriMenu.popup).toHaveBeenNthCalledWith(2); + expect(tauriMenu.close).toHaveBeenCalledOnce(); + }); + + it("does not hide a positioned popup failure without fallback", async () => { + const positionedFailure = new Error("position unsupported"); + const position = { type: "Logical", x: 10, y: 20 } as never; + tauriMenu.popup.mockRejectedValueOnce(positionedFailure); + + await expect( + popupNativeMenu({ + source: "positioned-menu", + buildItems: () => [{ text: "File" }], + at: position, + }) + ).rejects.toBe(positionedFailure); + + expect(tauriMenu.popup).toHaveBeenCalledOnce(); + expect(tauriMenu.close).toHaveBeenCalledOnce(); + }); + + it("shares the active gate across hot module reloads", async () => { + const activeBuild = deferred<[{ text: string }]>(); + const firstResultPromise = popupNativeMenu({ + source: "pre-reload-menu", + buildItems: () => activeBuild.promise, + }); + + vi.resetModules(); + const reloadedModule = await import("./nativeMenuPopup"); + const reloadedBuild = vi.fn(() => [{ text: "Reloaded" }]); + await expect( + reloadedModule.popupNativeMenu({ + source: "post-reload-menu", + buildItems: reloadedBuild, + }) + ).resolves.toEqual({ + status: "busy", + activeSource: "pre-reload-menu", + }); + expect(reloadedBuild).not.toHaveBeenCalled(); + + activeBuild.resolve([{ text: "Active" }]); + await firstResultPromise; + }); + + it("keeps all native menu IPC and resource ownership in this module", () => { + const sourceRoot = path.resolve(process.cwd(), "src"); + const ownerFile = path.resolve( + sourceRoot, + "util/platform/tauri/nativeMenuPopup.ts" + ); + const forbiddenPatterns = [ + /@tauri-apps\/api\/menu/, + /\b(?:Menu|TauriMenu|MenuItem|PredefinedMenuItem)\.new\s*\(/, + /\.popup\s*\(/, + ]; + const violatingFiles = listSourceFiles(sourceRoot) + .filter((file) => !/\.test\.tsx?$/.test(file)) + .filter((file) => file !== ownerFile) + .filter((file) => { + const source = readFileSync(file, "utf8"); + return forbiddenPatterns.some((pattern) => pattern.test(source)); + }) + .map((file) => path.relative(sourceRoot, file)); + + expect(violatingFiles).toEqual([]); + }); +}); diff --git a/src/util/platform/tauri/nativeMenuPopup.ts b/src/util/platform/tauri/nativeMenuPopup.ts new file mode 100644 index 0000000000..7fe35342f5 --- /dev/null +++ b/src/util/platform/tauri/nativeMenuPopup.ts @@ -0,0 +1,152 @@ +/** Central native context-menu lifecycle for the current WebView. */ +import type { + LogicalPosition, + PhysicalPosition, + Position, +} from "@tauri-apps/api/dpi"; +import { + type CheckMenuItemOptions, + type IconMenuItemOptions, + Menu, + type MenuItemOptions, + type PredefinedMenuItemOptions, + type SubmenuOptions, +} from "@tauri-apps/api/menu"; + +/** + * Plain menu options accepted by Tauri's `Menu.new({ items })` API. + * + * Callers deliberately provide options instead of creating menu resources. + * This keeps every native-menu IPC call and resource lifetime in this module. + */ +export type NativeMenuItemOptions = + | MenuItemOptions + | PredefinedMenuItemOptions + | CheckMenuItemOptions + | IconMenuItemOptions + | SubmenuOptions; + +export interface NativeMenuPopupBusy { + status: "busy"; + activeSource: string; +} + +export interface NativeMenuPopupEmpty { + status: "empty"; +} + +export interface NativeMenuPopupClosed { + status: "closed"; +} + +export type NativeMenuPopupResult = + | NativeMenuPopupBusy + | NativeMenuPopupEmpty + | NativeMenuPopupClosed; + +export interface PopupNativeMenuOptions { + /** Stable diagnostic name for the UI surface requesting the menu. */ + source: string; + /** + * Builds a fresh options array after this request owns the popup gate. + * Tauri mutates action-bearing option objects while serializing them. + */ + buildItems: () => NativeMenuItemOptions[] | Promise; + /** Optional position relative to the current window. */ + at?: LogicalPosition | PhysicalPosition | Position; + /** Retry at the current cursor if a positioned popup is unsupported. */ + fallbackToCursor?: boolean; + /** Called when another native menu already owns the popup lifecycle. */ + onBusy?: (activeSource: string) => void; +} + +interface ActiveNativeMenuPopup { + source: string; + token: object; +} + +interface NativeMenuPopupState { + active: ActiveNativeMenuPopup | null; +} + +const NATIVE_MENU_STATE_KEY = Symbol.for("orgii.tauri.native-menu-popup.v2"); + +function getState(): NativeMenuPopupState { + const host = globalThis as unknown as Record; + const existing = host[NATIVE_MENU_STATE_KEY]; + if (existing) return existing as NativeMenuPopupState; + + const state: NativeMenuPopupState = { active: null }; + host[NATIVE_MENU_STATE_KEY] = state; + return state; +} + +/** + * Owns the complete lifecycle of one native context menu. + * + * Tauri's popup command retains the WebView resource-table lock while the + * native menu tracks input. Claiming this non-queueing gate before the first + * menu IPC prevents a nested popup from waiting on that same lock forever. + * Duplicate requests are dropped because replaying a context menu after the + * originating interaction has ended would be stale UI. + */ +export async function popupNativeMenu({ + source, + buildItems, + at, + fallbackToCursor = false, + onBusy, +}: PopupNativeMenuOptions): Promise { + const state = getState(); + if (state.active) { + onBusy?.(state.active.source); + return { status: "busy", activeSource: state.active.source }; + } + + const token = {}; + state.active = { source, token }; + + try { + const items = await buildItems(); + if (items.length === 0) return { status: "empty" }; + + const menu = await Menu.new({ items }); + let popupError: unknown; + try { + if (at) { + try { + await menu.popup(at); + } catch (error) { + if (!fallbackToCursor) throw error; + await menu.popup(); + } + } else { + await menu.popup(); + } + } catch (error) { + popupError = error; + } + + let closeError: unknown; + try { + await menu.close(); + } catch (error) { + closeError = error; + } + + if (popupError !== undefined && closeError !== undefined) { + throw Object.assign( + new Error("Native menu popup and cleanup both failed"), + { errors: [popupError, closeError] } + ); + } + if (popupError !== undefined) throw popupError; + if (closeError !== undefined) throw closeError; + + return { status: "closed" }; + } finally { + if (state.active?.token === token) { + state.active = null; + } + } +} diff --git a/src/util/platform/tauri/nativeMenuSingleFlight.test.ts b/src/util/platform/tauri/nativeMenuSingleFlight.test.ts deleted file mode 100644 index d6949dbc76..0000000000 --- a/src/util/platform/tauri/nativeMenuSingleFlight.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { readFileSync, readdirSync } from "node:fs"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { runNativeMenuSingleFlight } from "./nativeMenuSingleFlight"; - -function deferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, reject, resolve }; -} - -function listSourceFiles(directory: string): string[] { - return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const entryPath = path.join(directory, entry.name); - if (entry.isDirectory()) return listSourceFiles(entryPath); - return /\.tsx?$/.test(entry.name) ? [entryPath] : []; - }); -} - -describe("runNativeMenuSingleFlight", () => { - afterEach(() => { - vi.resetModules(); - }); - - it("drops a concurrent request before invoking its menu factory", async () => { - const firstRun = deferred(); - const firstTask = vi.fn(() => firstRun.promise); - const duplicateTask = vi.fn(async () => undefined); - - const firstResultPromise = runNativeMenuSingleFlight( - "file-explorer", - firstTask - ); - const duplicateResult = await runNativeMenuSingleFlight( - "tab-context-menu", - duplicateTask - ); - - expect(firstTask).toHaveBeenCalledOnce(); - expect(duplicateTask).not.toHaveBeenCalled(); - expect(duplicateResult).toEqual({ - status: "busy", - activeSource: "file-explorer", - }); - - firstRun.resolve(); - await expect(firstResultPromise).resolves.toEqual({ - status: "completed", - value: undefined, - }); - }); - - it("accepts a new request after the active popup completes", async () => { - const firstRun = deferred(); - const firstResultPromise = runNativeMenuSingleFlight( - "first-menu", - () => firstRun.promise - ); - firstRun.resolve(); - await firstResultPromise; - - const nextTask = vi.fn(async () => "opened"); - await expect( - runNativeMenuSingleFlight("next-menu", nextTask) - ).resolves.toEqual({ status: "completed", value: "opened" }); - expect(nextTask).toHaveBeenCalledOnce(); - }); - - it("releases the gate when menu construction or popup rejects", async () => { - const failure = new Error("popup failed"); - - await expect( - runNativeMenuSingleFlight("broken-menu", async () => { - throw failure; - }) - ).rejects.toBe(failure); - - await expect( - runNativeMenuSingleFlight("recovery-menu", async () => "recovered") - ).resolves.toEqual({ status: "completed", value: "recovered" }); - }); - - it("shares the active gate across hot module reloads", async () => { - const activeRun = deferred(); - const firstResultPromise = runNativeMenuSingleFlight( - "pre-reload-menu", - () => activeRun.promise - ); - - vi.resetModules(); - const reloadedModule = await import("./nativeMenuSingleFlight"); - const reloadedTask = vi.fn(async () => undefined); - await expect( - reloadedModule.runNativeMenuSingleFlight("post-reload-menu", reloadedTask) - ).resolves.toEqual({ - status: "busy", - activeSource: "pre-reload-menu", - }); - expect(reloadedTask).not.toHaveBeenCalled(); - - activeRun.resolve(); - await firstResultPromise; - }); - - it("keeps every native popup entry point behind the coordinator", () => { - const sourceRoot = path.resolve(process.cwd(), "src"); - const unguardedFiles = listSourceFiles(sourceRoot) - .filter((file) => !file.endsWith(".test.ts")) - .filter((file) => { - const source = readFileSync(file, "utf8"); - return ( - source.includes("@tauri-apps/api/menu") && - /\.popup\s*\(/.test(source) && - !source.includes("runNativeMenuSingleFlight") - ); - }) - .map((file) => path.relative(sourceRoot, file)); - - expect(unguardedFiles).toEqual([]); - }); -}); diff --git a/src/util/platform/tauri/nativeMenuSingleFlight.ts b/src/util/platform/tauri/nativeMenuSingleFlight.ts deleted file mode 100644 index eb36530876..0000000000 --- a/src/util/platform/tauri/nativeMenuSingleFlight.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Serializes native menu construction and popup work within one WebView. - * - * Tauri's menu popup command retains the WebView resource-table lock while the - * native menu is tracking input. A nested menu request can otherwise re-enter - * the same WebView and wait forever while the popup waits on the UI thread. - * Duplicate requests are intentionally dropped instead of queued because a - * context menu opened after the original interaction has ended is stale UI. - */ - -export interface NativeMenuSingleFlightBusy { - status: "busy"; - activeSource: string; -} - -export interface NativeMenuSingleFlightCompleted { - status: "completed"; - value: T; -} - -export type NativeMenuSingleFlightResult = - | NativeMenuSingleFlightBusy - | NativeMenuSingleFlightCompleted; - -interface ActiveNativeMenuRun { - source: string; - token: object; -} - -interface NativeMenuSingleFlightState { - active: ActiveNativeMenuRun | null; -} - -const NATIVE_MENU_STATE_KEY = Symbol.for( - "orgii.tauri.native-menu-single-flight.v1" -); - -function getState(): NativeMenuSingleFlightState { - const host = globalThis as unknown as Record; - const existing = host[NATIVE_MENU_STATE_KEY]; - if (existing) return existing as NativeMenuSingleFlightState; - - const state: NativeMenuSingleFlightState = { active: null }; - host[NATIVE_MENU_STATE_KEY] = state; - return state; -} - -/** - * Runs all resource creation and popup work for one native menu as a single - * non-queueing critical section. The gate is claimed synchronously before the - * task starts, including before its first Tauri IPC call. - */ -export async function runNativeMenuSingleFlight( - source: string, - task: () => Promise -): Promise> { - const state = getState(); - if (state.active) { - return { status: "busy", activeSource: state.active.source }; - } - - const token = {}; - state.active = { source, token }; - - try { - return { status: "completed", value: await task() }; - } finally { - if (state.active?.token === token) { - state.active = null; - } - } -}