Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 0 additions & 88 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
dialog,
ipcMain,
Menu,
nativeImage,
nativeTheme,
protocol,
shell,
Expand All @@ -24,7 +23,6 @@ import type {
} from "@t3tools/contracts";
import { autoUpdater } from "electron-updater";

import type { ContextMenuItem } from "@t3tools/contracts";
import { NetService } from "@t3tools/shared/Net";
import { RotatingFileSink } from "@t3tools/shared/logging";
import { showDesktopConfirmDialog } from "./confirmDialog";
Expand All @@ -49,7 +47,6 @@ fixPath();
const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
const CONFIRM_CHANNEL = "desktop:confirm";
const SET_THEME_CHANNEL = "desktop:set-theme";
const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
const MENU_ACTION_CHANNEL = "desktop:menu-action";
const UPDATE_STATE_CHANNEL = "desktop:update-state";
Expand Down Expand Up @@ -92,7 +89,6 @@ let desktopLogSink: RotatingFileSink | null = null;
let backendLogSink: RotatingFileSink | null = null;
let restoreStdIoCapture: (() => void) | null = null;

let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
const desktopRuntimeInfo = resolveDesktopRuntimeInfo({
platform: process.platform,
processArch: process.arch,
Expand Down Expand Up @@ -248,29 +244,6 @@ function captureBackendOutput(child: ChildProcess.ChildProcess): void {
}

initializePackagedLogging();

function getDestructiveMenuIcon(): Electron.NativeImage | undefined {
if (process.platform !== "darwin") return undefined;
if (destructiveMenuIconCache !== undefined) {
return destructiveMenuIconCache ?? undefined;
}
try {
const icon = nativeImage.createFromNamedImage("trash").resize({
width: 14,
height: 14,
});
if (icon.isEmpty()) {
destructiveMenuIconCache = null;
return undefined;
}
icon.setTemplateImage(true);
destructiveMenuIconCache = icon;
return icon;
} catch {
destructiveMenuIconCache = null;
return undefined;
}
}
let updatePollTimer: ReturnType<typeof setInterval> | null = null;
let updateStartupTimer: ReturnType<typeof setTimeout> | null = null;
let updateCheckInFlight = false;
Expand Down Expand Up @@ -1084,67 +1057,6 @@ function registerIpcHandlers(): void {
nativeTheme.themeSource = theme;
});

ipcMain.removeHandler(CONTEXT_MENU_CHANNEL);
ipcMain.handle(
CONTEXT_MENU_CHANNEL,
async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => {
const normalizedItems = items
.filter((item) => typeof item.id === "string" && typeof item.label === "string")
.map((item) => ({
id: item.id,
label: item.label,
destructive: item.destructive === true,
}));
if (normalizedItems.length === 0) {
return null;
}

const popupPosition =
position &&
Number.isFinite(position.x) &&
Number.isFinite(position.y) &&
position.x >= 0 &&
position.y >= 0
? {
x: Math.floor(position.x),
y: Math.floor(position.y),
}
: null;

const window = BrowserWindow.getFocusedWindow() ?? mainWindow;
if (!window) return null;

return new Promise<string | null>((resolve) => {
const template: MenuItemConstructorOptions[] = [];
let hasInsertedDestructiveSeparator = false;
for (const item of normalizedItems) {
if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
template.push({ type: "separator" });
hasInsertedDestructiveSeparator = true;
}
const itemOption: MenuItemConstructorOptions = {
label: item.label,
click: () => resolve(item.id),
};
if (item.destructive) {
const destructiveIcon = getDestructiveMenuIcon();
if (destructiveIcon) {
itemOption.icon = destructiveIcon;
}
}
template.push(itemOption);
}

const menu = Menu.buildFromTemplate(template);
menu.popup({
window,
...popupPosition,
callback: () => resolve(null),
});
});
},
);

ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL);
ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => {
const externalUrl = getSafeExternalUrl(rawUrl);
Expand Down
2 changes: 0 additions & 2 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { DesktopBridge } from "@t3tools/contracts";
const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
const CONFIRM_CHANNEL = "desktop:confirm";
const SET_THEME_CHANNEL = "desktop:set-theme";
const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
const MENU_ACTION_CHANNEL = "desktop:menu-action";
const UPDATE_STATE_CHANNEL = "desktop:update-state";
Expand All @@ -18,7 +17,6 @@ contextBridge.exposeInMainWorld("desktopBridge", {
pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position),
openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url),
onMenuAction: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1439,6 +1439,7 @@ export default function Sidebar() {
onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
void handleProjectContextMenu(project.id, {
x: event.clientX,
y: event.clientY,
Expand Down Expand Up @@ -1545,6 +1546,7 @@ export default function Sidebar() {
}}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
if (
selectedThreadIds.size > 0 &&
selectedThreadIds.has(thread.id)
Expand Down
57 changes: 43 additions & 14 deletions apps/web/src/contextMenuFallback.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ContextMenuItem } from "@t3tools/contracts";

/**
* Imperative DOM-based context menu for non-Electron environments.
* Imperative DOM-based context menu used for browser and desktop renderer surfaces.
* Shows a positioned dropdown and returns a promise that resolves
* with the clicked item id, or null if dismissed.
*/
Expand All @@ -10,21 +10,22 @@ export function showContextMenuFallback<T extends string>(
position?: { x: number; y: number },
): Promise<T | null> {
return new Promise<T | null>((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;z-index:9999";

const menu = document.createElement("div");
menu.className =
"fixed z-[10000] min-w-[140px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95";
"fixed z-[10000] min-w-32 rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg/5";
menu.style.visibility = "hidden";

const x = position?.x ?? 0;
const y = position?.y ?? 0;
menu.style.top = `${y}px`;
menu.style.left = `${x}px`;
let outsidePressEnabled = false;
let enableOutsidePressFrame = 0;

function cleanup(result: T | null) {
document.removeEventListener("keydown", onKeyDown);
overlay.remove();
document.removeEventListener("pointerdown", onPointerDown, true);
cancelAnimationFrame(enableOutsidePressFrame);
menu.remove();
resolve(result);
}
Expand All @@ -36,33 +37,61 @@ export function showContextMenuFallback<T extends string>(
}
}

overlay.addEventListener("mousedown", () => cleanup(null));
function onPointerDown(event: PointerEvent) {
if (!outsidePressEnabled) return;
const target = event.target;
if (target instanceof Node && menu.contains(target)) {
return;
}
event.preventDefault();
event.stopPropagation();
cleanup(null);
}

document.addEventListener("keydown", onKeyDown);
document.addEventListener("pointerdown", onPointerDown, true);

let hasInsertedDestructiveSeparator = false;
for (const item of items) {
const isDestructiveAction = item.destructive === true || item.id === "delete";
if (isDestructiveAction && !hasInsertedDestructiveSeparator && menu.childElementCount > 0) {
const separator = document.createElement("div");
separator.className = "mx-2 my-1 h-px bg-border";
menu.appendChild(separator);
hasInsertedDestructiveSeparator = true;
}

const btn = document.createElement("button");
btn.type = "button";
btn.textContent = item.label;
const isDestructiveAction = item.destructive === true || item.id === "delete";
btn.style.appearance = "none";
btn.style.setProperty("-webkit-appearance", "none");
btn.style.border = "0";
btn.style.background = "transparent";
btn.style.boxShadow = "none";
btn.style.outline = "none";
btn.style.font = "inherit";
btn.style.margin = "0";
btn.className = isDestructiveAction
? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default"
: "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default";
? "flex min-h-8 w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left text-base text-destructive outline-none hover:bg-accent hover:text-accent-foreground sm:min-h-7 sm:text-sm"
: "flex min-h-8 w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left text-base text-foreground outline-none hover:bg-accent hover:text-accent-foreground sm:min-h-7 sm:text-sm";
btn.addEventListener("click", () => cleanup(item.id));
menu.appendChild(btn);
}

document.body.appendChild(overlay);
document.body.appendChild(menu);

// Adjust if menu overflows viewport
requestAnimationFrame(() => {
// Position the menu before revealing it so edge clamping does not cause a visible jump.
enableOutsidePressFrame = requestAnimationFrame(() => {
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menu.style.left = `${window.innerWidth - rect.width - 4}px`;
}
if (rect.bottom > window.innerHeight) {
menu.style.top = `${window.innerHeight - rect.height - 4}px`;
}
menu.classList.add("animate-in", "fade-in", "zoom-in-95");
menu.style.visibility = "visible";
outsidePressEnabled = true;
});
});
}
10 changes: 4 additions & 6 deletions apps/web/src/wsNativeApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,14 +336,12 @@ describe("wsNativeApi", () => {
});
});

it("forwards context menu metadata to desktop bridge", async () => {
const showContextMenu = vi.fn().mockResolvedValue("delete");
it("uses fallback context menu even when desktop bridge is available", async () => {
showContextMenuFallbackMock.mockResolvedValue("delete");
Object.defineProperty(getWindowForTest(), "desktopBridge", {
configurable: true,
writable: true,
value: {
showContextMenu,
},
value: {},
});

const { createWsNativeApi } = await import("./wsNativeApi");
Expand All @@ -356,7 +354,7 @@ describe("wsNativeApi", () => {
{ x: 200, y: 300 },
);

expect(showContextMenu).toHaveBeenCalledWith(
expect(showContextMenuFallbackMock).toHaveBeenCalledWith(
[
{ id: "rename", label: "Rename thread" },
{ id: "delete", label: "Delete", destructive: true },
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/wsNativeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,9 @@ export function createWsNativeApi(): NativeApi {
items: readonly ContextMenuItem<T>[],
position?: { x: number; y: number },
): Promise<T | null> => {
if (window.desktopBridge) {
return window.desktopBridge.showContextMenu(items, position) as Promise<T | null>;
}
// Use the in-app menu on desktop too. Native Electron menus render an
// additional composited surface in the frameless window that clashes
// with the app UI and cannot be styled away from the renderer.
return showContextMenuFallback(items, position);
},
},
Expand Down
4 changes: 0 additions & 4 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,6 @@ export interface DesktopBridge {
pickFolder: () => Promise<string | null>;
confirm: (message: string) => Promise<boolean>;
setTheme: (theme: DesktopTheme) => Promise<void>;
showContextMenu: <T extends string>(
items: readonly ContextMenuItem<T>[],
position?: { x: number; y: number },
) => Promise<T | null>;
openExternal: (url: string) => Promise<boolean>;
onMenuAction: (listener: (action: string) => void) => () => void;
getUpdateState: () => Promise<DesktopUpdateState>;
Expand Down
Loading