From 0068a93cfac7f799efed7b2501a17e0b52fbf18c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Feb 2026 22:42:01 -0800 Subject: [PATCH] Add desktop-backed confirm dialog API for worktree deletion - add `dialogs.confirm` to native IPC/contracts and desktop preload/main bridge - switch sidebar worktree deletion prompt from `window.confirm` to native API - add `showDesktopConfirmDialog` helper with unit tests for dialog behavior --- apps/desktop/src/confirmDialog.test.ts | 57 ++++++++++++++++++++++++++ apps/desktop/src/confirmDialog.ts | 26 ++++++++++++ apps/desktop/src/main.ts | 12 ++++++ apps/desktop/src/preload.ts | 2 + apps/web/src/components/Sidebar.tsx | 4 +- apps/web/src/vite-env.d.ts | 1 + apps/web/src/wsNativeApi.ts | 6 +++ packages/contracts/src/ipc.ts | 1 + 8 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/confirmDialog.test.ts create mode 100644 apps/desktop/src/confirmDialog.ts diff --git a/apps/desktop/src/confirmDialog.test.ts b/apps/desktop/src/confirmDialog.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..60b93e453939c51bee96fc74ac0c40e4189cdb26 --- /dev/null +++ b/apps/desktop/src/confirmDialog.test.ts @@ -0,0 +1,57 @@ +import type { BrowserWindow } from "electron"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { showMessageBoxMock } = vi.hoisted(() => ({ + showMessageBoxMock: vi.fn(), +})); + +vi.mock("electron", () => ({ + dialog: { + showMessageBox: showMessageBoxMock, + }, +})); + +import { showDesktopConfirmDialog } from "./confirmDialog"; + +describe("showDesktopConfirmDialog", () => { + beforeEach(() => { + showMessageBoxMock.mockReset(); + }); + + it("returns false and does not open a dialog for empty messages", async () => { + const result = await showDesktopConfirmDialog(" ", null); + + expect(result).toBe(false); + expect(showMessageBoxMock).not.toHaveBeenCalled(); + }); + + it("opens a dialog for the focused window and returns true on confirm", async () => { + const ownerWindow = { id: 1 } as BrowserWindow; + showMessageBoxMock.mockResolvedValue({ response: 1 }); + + const result = await showDesktopConfirmDialog("Delete worktree?", ownerWindow); + + expect(result).toBe(true); + expect(showMessageBoxMock).toHaveBeenCalledWith( + ownerWindow, + expect.objectContaining({ + buttons: ["Cancel", "Confirm"], + message: "Delete worktree?", + }), + ); + }); + + it("opens an app-level dialog when there is no focused window", async () => { + showMessageBoxMock.mockResolvedValue({ response: 0 }); + + const result = await showDesktopConfirmDialog("Delete worktree?", null); + + expect(result).toBe(false); + expect(showMessageBoxMock).toHaveBeenCalledWith( + expect.objectContaining({ + buttons: ["Cancel", "Confirm"], + message: "Delete worktree?", + }), + ); + }); +}); diff --git a/apps/desktop/src/confirmDialog.ts b/apps/desktop/src/confirmDialog.ts new file mode 100644 index 0000000000000000000000000000000000000000..9238de50f9359e54cb04e3b104fea25499c569cc --- /dev/null +++ b/apps/desktop/src/confirmDialog.ts @@ -0,0 +1,26 @@ +import { type BrowserWindow, dialog } from "electron"; + +const CONFIRM_BUTTON_INDEX = 1; + +export async function showDesktopConfirmDialog( + message: string, + ownerWindow: BrowserWindow | null, +): Promise { + const normalizedMessage = message.trim(); + if (normalizedMessage.length === 0) { + return false; + } + + const options = { + type: "question" as const, + buttons: ["Cancel", "Confirm"], + defaultId: CONFIRM_BUTTON_INDEX, + cancelId: 0, + noLink: true, + message: normalizedMessage, + }; + const result = ownerWindow + ? await dialog.showMessageBox(ownerWindow, options) + : await dialog.showMessageBox(options); + return result.response === CONFIRM_BUTTON_INDEX; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a5f8aa39cffb7437867666ba97f722e26251c89c..a0c9c4692b8d84d81c18523ed6afd8ac0c6843c6 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -7,11 +7,13 @@ import path from "node:path"; import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron"; +import { showDesktopConfirmDialog } from "./confirmDialog"; import { fixPath } from "./fixPath"; fixPath(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +const CONFIRM_CHANNEL = "desktop:confirm"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const ROOT_DIR = path.resolve(__dirname, "../../.."); @@ -142,6 +144,16 @@ function registerIpcHandlers(): void { return result.filePaths[0] ?? null; }); + ipcMain.removeHandler(CONFIRM_CHANNEL); + ipcMain.handle(CONFIRM_CHANNEL, async (_event, message: unknown) => { + if (typeof message !== "string") { + return false; + } + + const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; + return showDesktopConfirmDialog(message, owner); + }); + ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); ipcMain.handle(CONTEXT_MENU_CHANNEL, async (_event, items: { id: string; label: string }[]) => { const window = BrowserWindow.getFocusedWindow() ?? mainWindow; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b1961d5442a5da326e6ccd9977b6062f72c7c6e2..fb90831fc0ded445d5ba9dcd85ef58c9b9c7a4c0 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer } from "electron"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +const CONFIRM_CHANNEL = "desktop:confirm"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; @@ -8,6 +9,7 @@ const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: () => wsUrl, pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise, + confirm: (message: string) => ipcRenderer.invoke(CONFIRM_CHANNEL, message) as Promise, showContextMenu: (items: { id: string; label: string }[]) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise, openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d4b569744cfe750d4d2f1855eae9007253a5b6ff..82b348936c45d136011a91d9a2cefa8e78045b71 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -267,14 +267,14 @@ export default function Sidebar() { const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId); const shouldDeleteWorktree = orphanedWorktreePath !== null && - window.confirm( + (await api.dialogs.confirm( [ "This thread is the only one linked to this worktree:", orphanedWorktreePath, "", "Delete the worktree too?", ].join("\n"), - ); + )); // Stop active session if running if (thread.session?.sessionId) { diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 0300a966584810e777e99daf5fa0f62982fea057..3438d5d7b236ef604eb8111f1a531ddaefd8ea47 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -5,6 +5,7 @@ import type { NativeApi } from "@t3tools/contracts"; interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; + confirm: (message: string) => Promise; showContextMenu: (items: readonly { id: string; label: string }[]) => Promise; openExternal: (url: string) => Promise; } diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 36af2efddbc93ab0bf5162ed2885204e9f4a7b3a..9d0b953564f39c6867001fc9983628ee189e2b61 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -60,6 +60,12 @@ export function createWsNativeApi(): NativeApi { if (!window.desktopBridge) return null; return window.desktopBridge.pickFolder(); }, + confirm: async (message) => { + if (window.desktopBridge) { + return window.desktopBridge.confirm(message); + } + return window.confirm(message); + }, }, terminal: { open: (input) => transport.request(WS_METHODS.terminalOpen, input), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a3f67ced9aee637247096261550663419d79be78..2edd946418ae215a985467e9511d275b886a0b1a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -56,6 +56,7 @@ export interface NativeApi { }; dialogs: { pickFolder: () => Promise; + confirm: (message: string) => Promise; }; terminal: { open: (input: TerminalOpenInput) => Promise; -- 2.54.0