| 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,13 +5,14 @@ import net from "node:net"; |
| 5 | import os from "node:os"; | 5 | import os from "node:os"; |
| 6 | import path from "node:path"; | 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 | import { fixPath } from "./fixPath"; | 10 | import { fixPath } from "./fixPath"; |
| 11 | 11 | ||
| 12 | fixPath(); | 12 | fixPath(); |
| 13 | 13 | ||
| 14 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; | 14 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 15 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | ||
| 15 | const ROOT_DIR = path.resolve(__dirname, "../../.."); | 16 | const ROOT_DIR = path.resolve(__dirname, "../../.."); |
| 16 | const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs"); | 17 | const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs"); |
| 17 | const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); | 18 | const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); |
| ... | @@ -139,6 +140,25 @@ function registerIpcHandlers(): void { | ... | @@ -139,6 +140,25 @@ function registerIpcHandlers(): void { |
| 139 | if (result.canceled) return null; | 140 | if (result.canceled) return null; |
| 140 | return result.filePaths[0] ?? null; | 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 | function createWindow(): BrowserWindow { | 164 | function createWindow(): BrowserWindow { |
apps/desktop/src/preload.ts+3| ... | @@ -1,9 +1,12 @@ | ... | @@ -1,9 +1,12 @@ |
| 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 CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | ||
| 4 | const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; | 5 | const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; |
| 5 | 6 | ||
| 6 | contextBridge.exposeInMainWorld("desktopBridge", { | 7 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 7 | getWsUrl: () => wsUrl, | 8 | getWsUrl: () => wsUrl, |
| 8 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>, | 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,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 | useEffect(() => { | 190 | useEffect(() => { |
| 166 | const onWindowKeyDown = (event: KeyboardEvent) => { | 191 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 167 | const isNewThreadShortcut = | 192 | const isNewThreadShortcut = |
| ... | @@ -292,6 +317,13 @@ export default function Sidebar() { | ... | @@ -292,6 +317,13 @@ export default function Sidebar() { |
| 292 | threadId: thread.id, | 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 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> | 328 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> |
| 297 | {threadStatus && ( | 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,7 +39,8 @@ type Action = |
| 39 | branch: string | null; | 39 | branch: string | null; |
| 40 | worktreePath: string | null; | 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 | // ── State ──────────────────────────────────────────────────────────── | 45 | // ── State ──────────────────────────────────────────────────────────── |
| 45 | 46 | ||
| ... | @@ -391,6 +392,15 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -391,6 +392,15 @@ export function reducer(state: AppState, action: Action): AppState { |
| 391 | runtimeMode: action.mode, | 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 | default: | 404 | default: |
| 395 | return state; | 405 | return state; |
| 396 | } | 406 | } |
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 | showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>; | ||
| 8 | } | 9 | } |
| 9 | 10 | ||
| 10 | declare global { | 11 | declare global { |
apps/web/src/wsNativeApi.ts+12| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts"; | 1 | import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import { showContextMenuFallback } from "./contextMenuFallback"; | ||
| 3 | import { WsTransport } from "./wsTransport"; | 4 | import { WsTransport } from "./wsTransport"; |
| 4 | 5 | ||
| 5 | let instance: { api: NativeApi; transport: WsTransport } | null = null; | 6 | let instance: { api: NativeApi; transport: WsTransport } | null = null; |
| ... | @@ -105,6 +106,17 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -105,6 +106,17 @@ export function createWsNativeApi(): NativeApi { |
| 105 | checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), | 106 | checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), |
| 106 | init: (input) => transport.request(WS_METHODS.gitInit, input), | 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 | instance = { api, transport }; | 122 | instance = { api, transport }; |
packages/contracts/src/ipc.ts+6| ... | @@ -80,4 +80,10 @@ export interface NativeApi { | ... | @@ -80,4 +80,10 @@ export interface NativeApi { |
| 80 | checkout: (input: GitCheckoutInput) => Promise<void>; | 80 | checkout: (input: GitCheckoutInput) => Promise<void>; |
| 81 | init: (input: GitInitInput) => Promise<void>; | 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 | } |