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