authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 22:42:01-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-12 22:57:56-08:00
log0068a93cfac7f799efed7b2501a17e0b52fbf18c
tree7fb98a8d2e5c91f555da9c51538567d788801623
parentdd912666f2d1d96081febd52000f01c80aa21420

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

8 files changed, 107 insertions(+), 2 deletions(-)

apps/desktop/src/confirmDialog.test.ts created+57
......@@ -0,0 +1,57 @@
1import type { BrowserWindow } from "electron";
2import { beforeEach, describe, expect, it, vi } from "vitest";
3
4const { showMessageBoxMock } = vi.hoisted(() => ({
5 showMessageBoxMock: vi.fn(),
6}));
7
8vi.mock("electron", () => ({
9 dialog: {
10 showMessageBox: showMessageBoxMock,
11 },
12}));
13
14import { showDesktopConfirmDialog } from "./confirmDialog";
15
16describe("showDesktopConfirmDialog", () => {
17 beforeEach(() => {
18 showMessageBoxMock.mockReset();
19 });
20
21 it("returns false and does not open a dialog for empty messages", async () => {
22 const result = await showDesktopConfirmDialog(" ", null);
23
24 expect(result).toBe(false);
25 expect(showMessageBoxMock).not.toHaveBeenCalled();
26 });
27
28 it("opens a dialog for the focused window and returns true on confirm", async () => {
29 const ownerWindow = { id: 1 } as BrowserWindow;
30 showMessageBoxMock.mockResolvedValue({ response: 1 });
31
32 const result = await showDesktopConfirmDialog("Delete worktree?", ownerWindow);
33
34 expect(result).toBe(true);
35 expect(showMessageBoxMock).toHaveBeenCalledWith(
36 ownerWindow,
37 expect.objectContaining({
38 buttons: ["Cancel", "Confirm"],
39 message: "Delete worktree?",
40 }),
41 );
42 });
43
44 it("opens an app-level dialog when there is no focused window", async () => {
45 showMessageBoxMock.mockResolvedValue({ response: 0 });
46
47 const result = await showDesktopConfirmDialog("Delete worktree?", null);
48
49 expect(result).toBe(false);
50 expect(showMessageBoxMock).toHaveBeenCalledWith(
51 expect.objectContaining({
52 buttons: ["Cancel", "Confirm"],
53 message: "Delete worktree?",
54 }),
55 );
56 });
57});
apps/desktop/src/confirmDialog.ts created+26
......@@ -0,0 +1,26 @@
1import { type BrowserWindow, dialog } from "electron";
2
3const CONFIRM_BUTTON_INDEX = 1;
4
5export async function showDesktopConfirmDialog(
6 message: string,
7 ownerWindow: BrowserWindow | null,
8): Promise<boolean> {
9 const normalizedMessage = message.trim();
10 if (normalizedMessage.length === 0) {
11 return false;
12 }
13
14 const options = {
15 type: "question" as const,
16 buttons: ["Cancel", "Confirm"],
17 defaultId: CONFIRM_BUTTON_INDEX,
18 cancelId: 0,
19 noLink: true,
20 message: normalizedMessage,
21 };
22 const result = ownerWindow
23 ? await dialog.showMessageBox(ownerWindow, options)
24 : await dialog.showMessageBox(options);
25 return result.response === CONFIRM_BUTTON_INDEX;
26}
apps/desktop/src/main.ts+12
......@@ -7,11 +7,13 @@ import path from "node:path";
77
88import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron";
99
10import { showDesktopConfirmDialog } from "./confirmDialog";
1011import { fixPath } from "./fixPath";
1112
1213fixPath();
1314
1415const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
16const CONFIRM_CHANNEL = "desktop:confirm";
1517const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
1618const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
1719const ROOT_DIR = path.resolve(__dirname, "../../..");
......@@ -142,6 +144,16 @@ function registerIpcHandlers(): void {
142144 return result.filePaths[0] ?? null;
143145 });
144146
147 ipcMain.removeHandler(CONFIRM_CHANNEL);
148 ipcMain.handle(CONFIRM_CHANNEL, async (_event, message: unknown) => {
149 if (typeof message !== "string") {
150 return false;
151 }
152
153 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
154 return showDesktopConfirmDialog(message, owner);
155 });
156
145157 ipcMain.removeHandler(CONTEXT_MENU_CHANNEL);
146158 ipcMain.handle(CONTEXT_MENU_CHANNEL, async (_event, items: { id: string; label: string }[]) => {
147159 const window = BrowserWindow.getFocusedWindow() ?? mainWindow;
apps/desktop/src/preload.ts+2
......@@ -1,6 +1,7 @@
11import { contextBridge, ipcRenderer } from "electron";
22
33const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
4const CONFIRM_CHANNEL = "desktop:confirm";
45const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
56const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
67const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
......@@ -8,6 +9,7 @@ const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
89contextBridge.exposeInMainWorld("desktopBridge", {
910 getWsUrl: () => wsUrl,
1011 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>,
12 confirm: (message: string) => ipcRenderer.invoke(CONFIRM_CHANNEL, message) as Promise<boolean>,
1113 showContextMenu: (items: { id: string; label: string }[]) =>
1214 ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>,
1315 openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise<boolean>,
apps/web/src/components/Sidebar.tsx+2-2
......@@ -267,14 +267,14 @@ export default function Sidebar() {
267267 const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId);
268268 const shouldDeleteWorktree =
269269 orphanedWorktreePath !== null &&
270 window.confirm(
270 (await api.dialogs.confirm(
271271 [
272272 "This thread is the only one linked to this worktree:",
273273 orphanedWorktreePath,
274274 "",
275275 "Delete the worktree too?",
276276 ].join("\n"),
277 );
277 ));
278278
279279 // Stop active session if running
280280 if (thread.session?.sessionId) {
apps/web/src/vite-env.d.ts+1
......@@ -5,6 +5,7 @@ import type { NativeApi } from "@t3tools/contracts";
55interface DesktopBridge {
66 getWsUrl: () => string | null;
77 pickFolder: () => Promise<string | null>;
8 confirm: (message: string) => Promise<boolean>;
89 showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>;
910 openExternal: (url: string) => Promise<boolean>;
1011}
apps/web/src/wsNativeApi.ts+6
......@@ -60,6 +60,12 @@ export function createWsNativeApi(): NativeApi {
6060 if (!window.desktopBridge) return null;
6161 return window.desktopBridge.pickFolder();
6262 },
63 confirm: async (message) => {
64 if (window.desktopBridge) {
65 return window.desktopBridge.confirm(message);
66 }
67 return window.confirm(message);
68 },
6369 },
6470 terminal: {
6571 open: (input) => transport.request(WS_METHODS.terminalOpen, input),
packages/contracts/src/ipc.ts+1
......@@ -56,6 +56,7 @@ export interface NativeApi {
5656 };
5757 dialogs: {
5858 pickFolder: () => Promise<string | null>;
59 confirm: (message: string) => Promise<boolean>;
5960 };
6061 terminal: {
6162 open: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>;