| author | |
| committer | |
| log | 0068a93cfac7f799efed7b2501a17e0b52fbf18c |
| tree | 7fb98a8d2e5c91f555da9c51538567d788801623 |
| parent | dd912666f2d1d96081febd52000f01c80aa21420 |
- 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 behavior8 files changed, 107 insertions(+), 2 deletions(-)
apps/desktop/src/confirmDialog.test.ts created+57| ... | ... | @@ -0,0 +1,57 @@ |
| 1 | import type { BrowserWindow } from "electron"; | |
| 2 | import { beforeEach, describe, expect, it, vi } from "vitest"; | |
| 3 | ||
| 4 | const { showMessageBoxMock } = vi.hoisted(() => ({ | |
| 5 | showMessageBoxMock: vi.fn(), | |
| 6 | })); | |
| 7 | ||
| 8 | vi.mock("electron", () => ({ | |
| 9 | dialog: { | |
| 10 | showMessageBox: showMessageBoxMock, | |
| 11 | }, | |
| 12 | })); | |
| 13 | ||
| 14 | import { showDesktopConfirmDialog } from "./confirmDialog"; | |
| 15 | ||
| 16 | describe("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 @@ |
| 1 | import { type BrowserWindow, dialog } from "electron"; | |
| 2 | ||
| 3 | const CONFIRM_BUTTON_INDEX = 1; | |
| 4 | ||
| 5 | export 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"; |
| 7 | 7 | |
| 8 | 8 | import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron"; |
| 9 | 9 | |
| 10 | import { showDesktopConfirmDialog } from "./confirmDialog"; | |
| 10 | 11 | import { fixPath } from "./fixPath"; |
| 11 | 12 | |
| 12 | 13 | fixPath(); |
| 13 | 14 | |
| 14 | 15 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 16 | const CONFIRM_CHANNEL = "desktop:confirm"; | |
| 15 | 17 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 16 | 18 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 17 | 19 | const ROOT_DIR = path.resolve(__dirname, "../../.."); |
| ... | ... | @@ -142,6 +144,16 @@ function registerIpcHandlers(): void { |
| 142 | 144 | return result.filePaths[0] ?? null; |
| 143 | 145 | }); |
| 144 | 146 | |
| 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 | ||
| 145 | 157 | ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); |
| 146 | 158 | ipcMain.handle(CONTEXT_MENU_CHANNEL, async (_event, items: { id: string; label: string }[]) => { |
| 147 | 159 | const window = BrowserWindow.getFocusedWindow() ?? mainWindow; |
apps/desktop/src/preload.ts+2| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { contextBridge, ipcRenderer } from "electron"; |
| 2 | 2 | |
| 3 | 3 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 4 | const CONFIRM_CHANNEL = "desktop:confirm"; | |
| 4 | 5 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 5 | 6 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 6 | 7 | const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; |
| ... | ... | @@ -8,6 +9,7 @@ const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; |
| 8 | 9 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 9 | 10 | getWsUrl: () => wsUrl, |
| 10 | 11 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>, |
| 12 | confirm: (message: string) => ipcRenderer.invoke(CONFIRM_CHANNEL, message) as Promise<boolean>, | |
| 11 | 13 | showContextMenu: (items: { id: string; label: string }[]) => |
| 12 | 14 | ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>, |
| 13 | 15 | 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() { |
| 267 | 267 | const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId); |
| 268 | 268 | const shouldDeleteWorktree = |
| 269 | 269 | orphanedWorktreePath !== null && |
| 270 | window.confirm( | |
| 270 | (await api.dialogs.confirm( | |
| 271 | 271 | [ |
| 272 | 272 | "This thread is the only one linked to this worktree:", |
| 273 | 273 | orphanedWorktreePath, |
| 274 | 274 | "", |
| 275 | 275 | "Delete the worktree too?", |
| 276 | 276 | ].join("\n"), |
| 277 | ); | |
| 277 | )); | |
| 278 | 278 | |
| 279 | 279 | // Stop active session if running |
| 280 | 280 | if (thread.session?.sessionId) { |
apps/web/src/vite-env.d.ts+1| ... | ... | @@ -5,6 +5,7 @@ import type { NativeApi } from "@t3tools/contracts"; |
| 5 | 5 | interface DesktopBridge { |
| 6 | 6 | getWsUrl: () => string | null; |
| 7 | 7 | pickFolder: () => Promise<string | null>; |
| 8 | confirm: (message: string) => Promise<boolean>; | |
| 8 | 9 | showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>; |
| 9 | 10 | openExternal: (url: string) => Promise<boolean>; |
| 10 | 11 | } |
apps/web/src/wsNativeApi.ts+6| ... | ... | @@ -60,6 +60,12 @@ export function createWsNativeApi(): NativeApi { |
| 60 | 60 | if (!window.desktopBridge) return null; |
| 61 | 61 | return window.desktopBridge.pickFolder(); |
| 62 | 62 | }, |
| 63 | confirm: async (message) => { | |
| 64 | if (window.desktopBridge) { | |
| 65 | return window.desktopBridge.confirm(message); | |
| 66 | } | |
| 67 | return window.confirm(message); | |
| 68 | }, | |
| 63 | 69 | }, |
| 64 | 70 | terminal: { |
| 65 | 71 | open: (input) => transport.request(WS_METHODS.terminalOpen, input), |
packages/contracts/src/ipc.ts+1| ... | ... | @@ -56,6 +56,7 @@ export interface NativeApi { |
| 56 | 56 | }; |
| 57 | 57 | dialogs: { |
| 58 | 58 | pickFolder: () => Promise<string | null>; |
| 59 | confirm: (message: string) => Promise<boolean>; | |
| 59 | 60 | }; |
| 60 | 61 | terminal: { |
| 61 | 62 | open: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>; |