| author | |
| committer | |
| log | 6e3726ec32bb7d93b6004fb941df20876485032f |
| tree | dbbf2c30313d169fff5853196262be7441826c60 |
| parent | 7e5cd01d26b21093947cb9b988f5963b8ed6ce26 |
| parent | 169d06f05b543433278a123b3512fddad05f9f2a |
# Conflicts:
#	apps/web/src/store.ts
#	apps/web/src/wsNativeApi.ts
#	packages/contracts/src/ipc.ts8 files changed, 150 insertions(+), 2 deletions(-)
apps/desktop/src/main.ts+21-1| ... | ... | @@ -5,13 +5,14 @@ import net from "node:net"; |
| 5 | 5 | import os from "node:os"; |
| 6 | 6 | import path from "node:path"; |
| 7 | 7 | |
| 8 | import { app, BrowserWindow, dialog, ipcMain } from "electron"; | |
| 8 | import { app, BrowserWindow, dialog, ipcMain, Menu } from "electron"; | |
| 9 | 9 | |
| 10 | 10 | import { fixPath } from "./fixPath"; |
| 11 | 11 | |
| 12 | 12 | fixPath(); |
| 13 | 13 | |
| 14 | 14 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 15 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | |
| 15 | 16 | const ROOT_DIR = path.resolve(__dirname, "../../.."); |
| 16 | 17 | const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs"); |
| 17 | 18 | const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); |
| ... | ... | @@ -139,6 +140,25 @@ function registerIpcHandlers(): void { |
| 139 | 140 | if (result.canceled) return null; |
| 140 | 141 | return result.filePaths[0] ?? null; |
| 141 | 142 | }); |
| 143 | ||
| 144 | ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); | |
| 145 | ipcMain.handle(CONTEXT_MENU_CHANNEL, async (_event, items: { id: string; label: string }[]) => { | |
| 146 | const window = BrowserWindow.getFocusedWindow() ?? mainWindow; | |
| 147 | if (!window) return null; | |
| 148 | ||
| 149 | return new Promise<string | null>((resolve) => { | |
| 150 | const menu = Menu.buildFromTemplate( | |
| 151 | items.map((item) => ({ | |
| 152 | label: item.label, | |
| 153 | click: () => resolve(item.id), | |
| 154 | })), | |
| 155 | ); | |
| 156 | menu.popup({ | |
| 157 | window, | |
| 158 | callback: () => resolve(null), | |
| 159 | }); | |
| 160 | }); | |
| 161 | }); | |
| 142 | 162 | } |
| 143 | 163 | |
| 144 | 164 | function createWindow(): BrowserWindow { |
apps/desktop/src/preload.ts+3| ... | ... | @@ -1,9 +1,12 @@ |
| 1 | 1 | import { contextBridge, ipcRenderer } from "electron"; |
| 2 | 2 | |
| 3 | 3 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 4 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | |
| 4 | 5 | const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; |
| 5 | 6 | |
| 6 | 7 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 7 | 8 | getWsUrl: () => wsUrl, |
| 8 | 9 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>, |
| 10 | showContextMenu: (items: { id: string; label: string }[]) => | |
| 11 | ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>, | |
| 9 | 12 | }); |
apps/web/src/components/Sidebar.tsx+32| ... | ... | @@ -162,6 +162,31 @@ export default function Sidebar() { |
| 162 | 162 | } |
| 163 | 163 | }; |
| 164 | 164 | |
| 165 | const handleThreadContextMenu = useCallback( | |
| 166 | async (threadId: string, position: { x: number; y: number }) => { | |
| 167 | if (!api) return; | |
| 168 | const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); | |
| 169 | if (clicked !== "delete") return; | |
| 170 | ||
| 171 | const thread = state.threads.find((t) => t.id === threadId); | |
| 172 | if (!thread) return; | |
| 173 | ||
| 174 | // Stop active session if running | |
| 175 | if (thread.session?.sessionId) { | |
| 176 | try { | |
| 177 | await api.providers.stopSession({ | |
| 178 | sessionId: thread.session.sessionId, | |
| 179 | }); | |
| 180 | } catch { | |
| 181 | // Session may already be stopped | |
| 182 | } | |
| 183 | } | |
| 184 | ||
| 185 | dispatch({ type: "DELETE_THREAD", threadId }); | |
| 186 | }, | |
| 187 | [api, dispatch, state.threads], | |
| 188 | ); | |
| 189 | ||
| 165 | 190 | useEffect(() => { |
| 166 | 191 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 167 | 192 | const isNewThreadShortcut = |
| ... | ... | @@ -292,6 +317,13 @@ export default function Sidebar() { |
| 292 | 317 | threadId: thread.id, |
| 293 | 318 | }) |
| 294 | 319 | } |
| 320 | onContextMenu={(e) => { | |
| 321 | e.preventDefault(); | |
| 322 | void handleThreadContextMenu(thread.id, { | |
| 323 | x: e.clientX, | |
| 324 | y: e.clientY, | |
| 325 | }); | |
| 326 | }} | |
| 295 | 327 | > |
| 296 | 328 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> |
| 297 | 329 | {threadStatus && ( |
apps/web/src/contextMenuFallback.ts created+64| ... | ... | @@ -0,0 +1,64 @@ |
| 1 | /** | |
| 2 | * Imperative DOM-based context menu for non-Electron environments. | |
| 3 | * Shows a positioned dropdown and returns a promise that resolves | |
| 4 | * with the clicked item id, or null if dismissed. | |
| 5 | */ | |
| 6 | export function showContextMenuFallback<T extends string>( | |
| 7 | items: readonly { id: T; label: string }[], | |
| 8 | position?: { x: number; y: number }, | |
| 9 | ): Promise<T | null> { | |
| 10 | return new Promise<T | null>((resolve) => { | |
| 11 | const overlay = document.createElement("div"); | |
| 12 | overlay.style.cssText = "position:fixed;inset:0;z-index:9999"; | |
| 13 | ||
| 14 | const menu = document.createElement("div"); | |
| 15 | menu.className = | |
| 16 | "fixed z-[10000] min-w-[140px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; | |
| 17 | ||
| 18 | const x = position?.x ?? 0; | |
| 19 | const y = position?.y ?? 0; | |
| 20 | menu.style.top = `${y}px`; | |
| 21 | menu.style.left = `${x}px`; | |
| 22 | ||
| 23 | function cleanup(result: T | null) { | |
| 24 | document.removeEventListener("keydown", onKeyDown); | |
| 25 | overlay.remove(); | |
| 26 | menu.remove(); | |
| 27 | resolve(result); | |
| 28 | } | |
| 29 | ||
| 30 | function onKeyDown(e: KeyboardEvent) { | |
| 31 | if (e.key === "Escape") { | |
| 32 | e.preventDefault(); | |
| 33 | cleanup(null); | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | overlay.addEventListener("mousedown", () => cleanup(null)); | |
| 38 | document.addEventListener("keydown", onKeyDown); | |
| 39 | ||
| 40 | for (const item of items) { | |
| 41 | const btn = document.createElement("button"); | |
| 42 | btn.type = "button"; | |
| 43 | btn.textContent = item.label; | |
| 44 | btn.className = | |
| 45 | "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default"; | |
| 46 | btn.addEventListener("click", () => cleanup(item.id)); | |
| 47 | menu.appendChild(btn); | |
| 48 | } | |
| 49 | ||
| 50 | document.body.appendChild(overlay); | |
| 51 | document.body.appendChild(menu); | |
| 52 | ||
| 53 | // Adjust if menu overflows viewport | |
| 54 | requestAnimationFrame(() => { | |
| 55 | const rect = menu.getBoundingClientRect(); | |
| 56 | if (rect.right > window.innerWidth) { | |
| 57 | menu.style.left = `${window.innerWidth - rect.width - 4}px`; | |
| 58 | } | |
| 59 | if (rect.bottom > window.innerHeight) { | |
| 60 | menu.style.top = `${window.innerHeight - rect.height - 4}px`; | |
| 61 | } | |
| 62 | }); | |
| 63 | }); | |
| 64 | } |
apps/web/src/store.ts+11-1| ... | ... | @@ -39,7 +39,8 @@ type Action = |
| 39 | 39 | branch: string | null; |
| 40 | 40 | worktreePath: string | null; |
| 41 | 41 | } |
| 42 | | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }; | |
| 42 | | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode } | |
| 43 | | { type: "DELETE_THREAD"; threadId: string }; | |
| 43 | 44 | |
| 44 | 45 | // ── State ──────────────────────────────────────────────────────────── |
| 45 | 46 | |
| ... | ... | @@ -391,6 +392,15 @@ export function reducer(state: AppState, action: Action): AppState { |
| 391 | 392 | runtimeMode: action.mode, |
| 392 | 393 | }; |
| 393 | 394 | |
| 395 | case "DELETE_THREAD": { | |
| 396 | const threads = state.threads.filter((t) => t.id !== action.threadId); | |
| 397 | const activeThreadId = | |
| 398 | state.activeThreadId === action.threadId | |
| 399 | ? (threads[0]?.id ?? null) | |
| 400 | : state.activeThreadId; | |
| 401 | return { ...state, threads, activeThreadId }; | |
| 402 | } | |
| 403 | ||
| 394 | 404 | default: |
| 395 | 405 | return state; |
| 396 | 406 | } |
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 | showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>; | |
| 8 | 9 | } |
| 9 | 10 | |
| 10 | 11 | declare global { |
apps/web/src/wsNativeApi.ts+12| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | import { showContextMenuFallback } from "./contextMenuFallback"; | |
| 3 | 4 | import { WsTransport } from "./wsTransport"; |
| 4 | 5 | |
| 5 | 6 | let instance: { api: NativeApi; transport: WsTransport } | null = null; |
| ... | ... | @@ -105,6 +106,17 @@ export function createWsNativeApi(): NativeApi { |
| 105 | 106 | checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), |
| 106 | 107 | init: (input) => transport.request(WS_METHODS.gitInit, input), |
| 107 | 108 | }, |
| 109 | contextMenu: { | |
| 110 | show: async <T extends string>( | |
| 111 | items: readonly { id: T; label: string }[], | |
| 112 | position?: { x: number; y: number }, | |
| 113 | ): Promise<T | null> => { | |
| 114 | if (window.desktopBridge) { | |
| 115 | return window.desktopBridge.showContextMenu(items) as Promise<T | null>; | |
| 116 | } | |
| 117 | return showContextMenuFallback(items, position); | |
| 118 | }, | |
| 119 | }, | |
| 108 | 120 | }; |
| 109 | 121 | |
| 110 | 122 | instance = { api, transport }; |
packages/contracts/src/ipc.ts+6| ... | ... | @@ -80,4 +80,10 @@ export interface NativeApi { |
| 80 | 80 | checkout: (input: GitCheckoutInput) => Promise<void>; |
| 81 | 81 | init: (input: GitInitInput) => Promise<void>; |
| 82 | 82 | }; |
| 83 | contextMenu: { | |
| 84 | show: <T extends string>( | |
| 85 | items: readonly { id: T; label: string }[], | |
| 86 | position?: { x: number; y: number }, | |
| 87 | ) => Promise<T | null>; | |
| 88 | }; | |
| 83 | 89 | } |