diff --git a/apps/desktop/src/lib/electron-app/factories/app/setup.ts b/apps/desktop/src/lib/electron-app/factories/app/setup.ts index 89b79eb4741..b8fdb0321a6 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.ts @@ -11,7 +11,10 @@ import { ignoreConsoleWarnings } from "../../utils/ignore-console-warnings"; ignoreConsoleWarnings(["Manifest version 2 is deprecated"]); -export async function makeAppSetup(createWindow: () => Promise) { +export async function makeAppSetup( + createWindow: () => Promise, + restoreWindows?: () => Promise, +) { if (ENVIRONMENT.IS_DEV) { try { await installExtension([REACT_DEVELOPER_TOOLS], { @@ -24,7 +27,19 @@ export async function makeAppSetup(createWindow: () => Promise) { } } - let window = await createWindow(); + // Restore windows from previous session if available + if (restoreWindows) { + await restoreWindows(); + } + + // If no windows were restored, create a new one + const existingWindows = BrowserWindow.getAllWindows(); + let window: BrowserWindow; + if (existingWindows.length > 0) { + window = existingWindows[0]; + } else { + window = await createWindow(); + } app.on("activate", async () => { const windows = BrowserWindow.getAllWindows(); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 67949e90882..ea0d4c5b8a1 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -50,6 +50,11 @@ app.on("open-url", (event, url) => { registerWorkspaceIPCs(); registerPortIpcs(); registerDeepLinkIpcs(); + const { registerWindowIPCs } = await import("main/lib/window-ipcs"); + registerWindowIPCs(); - await makeAppSetup(() => windowManager.createWindow()); + await makeAppSetup( + () => windowManager.createWindow(), + () => windowManager.restoreWindows(), + ); })(); diff --git a/apps/desktop/src/main/lib/window-ipcs.ts b/apps/desktop/src/main/lib/window-ipcs.ts index b230208e24f..48b91ebd4a1 100644 --- a/apps/desktop/src/main/lib/window-ipcs.ts +++ b/apps/desktop/src/main/lib/window-ipcs.ts @@ -1,4 +1,4 @@ -import { ipcMain } from "electron"; +import { BrowserWindow, ipcMain } from "electron"; import windowManager from "./window-manager"; export function registerWindowIPCs() { @@ -14,4 +14,12 @@ export function registerWindowIPCs() { }; } }); + + ipcMain.handle("window-is-restored", async (event) => { + const senderWindow = BrowserWindow.fromWebContents(event.sender); + if (!senderWindow) { + return false; + } + return windowManager.isRestoredWindow(senderWindow); + }); } diff --git a/apps/desktop/src/main/lib/window-manager.ts b/apps/desktop/src/main/lib/window-manager.ts index 5e71984aa0b..736cabd2991 100644 --- a/apps/desktop/src/main/lib/window-manager.ts +++ b/apps/desktop/src/main/lib/window-manager.ts @@ -1,19 +1,97 @@ import type { BrowserWindow } from "electron"; import { MainWindow } from "../windows/main"; +import windowStateManager from "./window-state-manager"; class WindowManager { private windows: Set = new Set(); private windowWorkspaces: Map = new Map(); + private restoredWindowIds: Set = new Set(); - async createWindow(): Promise { + async createWindow( + restoreState?: { workspaceId: string | null; bounds?: Electron.Rectangle }, + ): Promise { const window = await MainWindow(); + + // Restore window bounds if provided + if (restoreState?.bounds) { + window.setBounds(restoreState.bounds); + } + this.windows.add(window); - // New windows start with no workspace - user must select one - this.windowWorkspaces.set(window, null); + const workspaceId = restoreState?.workspaceId ?? null; + this.windowWorkspaces.set(window, workspaceId); + + // Mark as restored if we're restoring state + if (restoreState) { + this.restoredWindowIds.add(window.webContents.id); + } + + // Save window state when workspace changes + if (workspaceId) { + windowStateManager.saveWindowState(window, workspaceId); + } + + // Store window ID before it might be destroyed + const windowId = window.webContents.id; + + // Save window bounds periodically and on move/resize + const saveBounds = () => { + // Check if window still exists and is not destroyed + if (window.isDestroyed() || !this.windows.has(window)) { + return; + } + const currentWorkspaceId = this.windowWorkspaces.get(window) ?? null; + windowStateManager.saveWindowState(window, currentWorkspaceId); + }; + + window.on("moved", saveBounds); + window.on("resized", saveBounds); + + window.on("close", () => { + // Remove event listeners to prevent them from firing after close + window.removeListener("moved", saveBounds); + window.removeListener("resized", saveBounds); + + // Save final state before closing (window is still valid here) + // Get workspace ID from our map before window might be destroyed + const workspaceId = this.windowWorkspaces.get(window) ?? null; + + try { + if (!window.isDestroyed()) { + const bounds = window.getBounds(); + // Save using window ID to avoid issues if window is destroyed + windowStateManager.saveWindowStateById(windowId, workspaceId, bounds); + } else { + // Window already destroyed, use last known bounds from state + if (workspaceId) { + const lastState = windowStateManager.getWindowState(windowId); + if (lastState) { + windowStateManager.saveWindowStateById( + windowId, + workspaceId, + lastState.bounds, + ); + } + } + } + } catch (error) { + // Silently fail if window is destroyed - we'll clean up in closed handler + if (!(error instanceof Error && error.message.includes("destroyed"))) { + console.error("[WindowManager] Failed to save window state on close:", error); + } + } + }); window.on("closed", () => { + // Remove from state after window is fully closed + // Use stored window ID since window is now destroyed + setTimeout(() => { + windowStateManager.removeWindowState(windowId); + }, 100); + this.windows.delete(window); this.windowWorkspaces.delete(window); + this.restoredWindowIds.delete(windowId); }); return window; @@ -33,6 +111,43 @@ class WindowManager { setWorkspaceForWindow(window: BrowserWindow, workspaceId: string | null): void { this.windowWorkspaces.set(window, workspaceId); + // Persist the workspace association + windowStateManager.saveWindowState(window, workspaceId); + } + + isRestoredWindow(window: BrowserWindow): boolean { + return this.restoredWindowIds.has(window.webContents.id); + } + + async restoreWindows(): Promise { + const savedStates = windowStateManager.getWindowStates(); + + // Only restore windows that have a workspace assigned + // Windows without workspace were likely closed intentionally + const windowsToRestore = savedStates.filter((state) => state.workspaceId); + const windowsWithoutWorkspace = savedStates.filter( + (state) => !state.workspaceId, + ); + + // Clean up windows without workspaces (they were closed intentionally) + for (const state of windowsWithoutWorkspace) { + windowStateManager.removeWindowState(Number.parseInt(state.id, 10)); + } + + // Restore all saved windows with workspaces + for (const state of windowsToRestore) { + try { + await this.createWindow({ + workspaceId: state.workspaceId, + bounds: state.bounds, + }); + } catch (error) { + console.error( + `[WindowManager] Failed to restore window ${state.id}:`, + error, + ); + } + } } } diff --git a/apps/desktop/src/main/lib/window-state-manager.ts b/apps/desktop/src/main/lib/window-state-manager.ts new file mode 100644 index 00000000000..bea74137411 --- /dev/null +++ b/apps/desktop/src/main/lib/window-state-manager.ts @@ -0,0 +1,160 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { BrowserWindow } from "electron"; + +interface WindowState { + id: string; // webContents.id as string + workspaceId: string | null; + bounds: { + x: number; + y: number; + width: number; + height: number; + }; +} + +interface WindowStateConfig { + windows: WindowState[]; +} + +class WindowStateManager { + private static instance: WindowStateManager; + private statePath: string; + private stateDir: string; + + private constructor() { + this.stateDir = path.join(os.homedir(), ".superset"); + this.statePath = path.join(this.stateDir, "window-state.json"); + this.ensureStateExists(); + } + + static getInstance(): WindowStateManager { + if (!WindowStateManager.instance) { + WindowStateManager.instance = new WindowStateManager(); + } + return WindowStateManager.instance; + } + + private ensureStateExists(): void { + // Create directory if it doesn't exist + if (!existsSync(this.stateDir)) { + mkdirSync(this.stateDir, { recursive: true }); + } + + // Create state file with default structure if it doesn't exist + if (!existsSync(this.statePath)) { + const defaultState: WindowStateConfig = { + windows: [], + }; + writeFileSync( + this.statePath, + JSON.stringify(defaultState, null, 2), + "utf-8", + ); + } + } + + read(): WindowStateConfig { + try { + const content = readFileSync(this.statePath, "utf-8"); + return JSON.parse(content) as WindowStateConfig; + } catch (error) { + console.error("Failed to read window state:", error); + return { windows: [] }; + } + } + + write(state: WindowStateConfig): boolean { + try { + writeFileSync(this.statePath, JSON.stringify(state, null, 2), "utf-8"); + return true; + } catch (error) { + console.error("Failed to write window state:", error); + return false; + } + } + + saveWindowState(window: BrowserWindow, workspaceId: string | null): void { + // Check if window is destroyed before accessing properties + if (window.isDestroyed()) { + return; + } + + try { + const state = this.read(); + const windowId = String(window.webContents.id); + const bounds = window.getBounds(); + + const existingIndex = state.windows.findIndex((w) => w.id === windowId); + const windowState: WindowState = { + id: windowId, + workspaceId, + bounds, + }; + + if (existingIndex >= 0) { + state.windows[existingIndex] = windowState; + } else { + state.windows.push(windowState); + } + + this.write(state); + } catch (error) { + // Window might be destroyed between check and access + if (error instanceof Error && error.message.includes("destroyed")) { + return; + } + console.error("[WindowStateManager] Failed to save window state:", error); + } + } + + saveWindowStateById( + windowId: number, + workspaceId: string | null, + bounds: Electron.Rectangle, + ): void { + try { + const state = this.read(); + const id = String(windowId); + const windowState: WindowState = { + id, + workspaceId, + bounds, + }; + + const existingIndex = state.windows.findIndex((w) => w.id === id); + if (existingIndex >= 0) { + state.windows[existingIndex] = windowState; + } else { + state.windows.push(windowState); + } + + this.write(state); + } catch (error) { + console.error("[WindowStateManager] Failed to save window state by ID:", error); + } + } + + removeWindowState(windowId: number): void { + const state = this.read(); + state.windows = state.windows.filter((w) => w.id !== String(windowId)); + this.write(state); + } + + getWindowStates(): WindowState[] { + return this.read().windows; + } + + getWindowState(windowId: number): WindowState | undefined { + const state = this.read(); + return state.windows.find((w) => w.id === String(windowId)); + } + + clearAll(): void { + this.write({ windows: [] }); + } +} + +export default WindowStateManager.getInstance(); + diff --git a/apps/desktop/src/renderer/screens/main/MainScreen.tsx b/apps/desktop/src/renderer/screens/main/MainScreen.tsx index cc724799222..560ddbc28cc 100644 --- a/apps/desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/desktop/src/renderer/screens/main/MainScreen.tsx @@ -164,7 +164,7 @@ export function MainScreen() { /> {/* Main content area - conditionally render based on mode */} -
+
+
{children}
); diff --git a/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/PRActions.tsx b/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/PRActions.tsx index 77194327426..8a21a78ab6c 100644 --- a/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/PRActions.tsx +++ b/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/PRActions.tsx @@ -26,7 +26,7 @@ export const PRActions: React.FC = ({ variant="default" size="sm" onClick={onMergePR} - className="h-7 bg-green-600 hover:bg-green-700 text-white" + className="h-6 bg-green-600 hover:bg-green-700 text-white text-xs" > Merge PR @@ -48,6 +48,7 @@ export const PRActions: React.FC = ({ size="sm" onClick={onCreatePR} disabled={!canCreatePR} + className="h-6 text-xs" > Create PR diff --git a/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/SidebarToggle.tsx b/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/SidebarToggle.tsx index ea16de6216e..86155b9d67d 100644 --- a/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/SidebarToggle.tsx +++ b/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/SidebarToggle.tsx @@ -14,7 +14,7 @@ export const SidebarToggle: React.FC = ({ onCollapse, onExpand, }) => ( -
+
{isOpen ? ( diff --git a/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/TaskTabs.tsx b/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/TaskTabs.tsx index 698d9fbac3b..0465d4a2f1b 100644 --- a/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/TaskTabs.tsx +++ b/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/TaskTabs.tsx @@ -25,11 +25,11 @@ export const TaskTabs: React.FC = ({ return (
= ({ {onModeChange && } -
+
{worktrees.map((worktree, index) => { const isSelected = selectedWorktreeId === worktree.id; const prevWorktree = index > 0 ? worktrees[index - 1] : null; diff --git a/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/WorktreeTabButton.tsx b/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/WorktreeTabButton.tsx index f934391939e..3a44f055a50 100644 --- a/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/WorktreeTabButton.tsx +++ b/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/WorktreeTabButton.tsx @@ -27,10 +27,9 @@ export const WorktreeTabButton: React.FC = ({ disabled={isPending} className={` flex items-center gap-2 px-3 h-8 rounded-t-md transition-all - ${ - isSelected - ? "bg-neutral-900 text-white border-t border-x border-r border-neutral-700 -mb-px" - : "bg-transparent text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800/50" + ${isSelected + ? "text-white border-t border-x border-r border-b border-b-black -mb-px" + : "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800/50" } ${isPending ? "opacity-70 cursor-wait" : ""} `} diff --git a/apps/desktop/src/renderer/screens/main/components/MainContentArea/MainContentArea.tsx b/apps/desktop/src/renderer/screens/main/components/MainContentArea/MainContentArea.tsx index 7f4f6cb702d..500973b8123 100644 --- a/apps/desktop/src/renderer/screens/main/components/MainContentArea/MainContentArea.tsx +++ b/apps/desktop/src/renderer/screens/main/components/MainContentArea/MainContentArea.tsx @@ -199,7 +199,7 @@ export function MainContentArea({ /> ) : ( // Base level tab (terminal, preview, etc.) → display full width/height -
+
-
-
-

Files

-
-
- -
-
+
+
); } diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeContent/ModeContent.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeContent/ModeContent.tsx index 5a55d2d7573..a6cf821d37f 100644 --- a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeContent/ModeContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeContent/ModeContent.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; -import { ModeHeader } from "../ModeHeader"; import type { SidebarMode } from "../../types"; +import { ModeHeader } from "../ModeHeader"; interface ModeContentProps { mode: SidebarMode; @@ -18,8 +18,7 @@ export function ModeContent({ mode, children }: ModeContentProps) { }} > -
{children}
+
{children}
); } - diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeNavigation/ModeNavigation.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeNavigation/ModeNavigation.tsx index 41c78e472c6..9442120bd66 100644 --- a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeNavigation/ModeNavigation.tsx +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeNavigation/ModeNavigation.tsx @@ -1,5 +1,6 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import type { MotionValue } from "framer-motion"; -import { modeIcons } from "../../constants"; +import { modeIcons, modeLabels } from "../../constants"; import type { SidebarMode } from "../../types"; import { AnimatedBackground } from "../AnimatedBackground"; @@ -17,7 +18,7 @@ export function ModeNavigation({ scrollProgress, }: ModeNavigationProps) { return ( -
+
{ const Icon = modeIcons[mode]; const isActive = mode === currentMode; + const label = modeLabels[mode]; return ( - + + + + + +

{label}

+
+
); })}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSelectionModal/WorkspaceSelectionModal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSelectionModal/WorkspaceSelectionModal.tsx index 825209c14d3..393f20ff20e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSelectionModal/WorkspaceSelectionModal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSelectionModal/WorkspaceSelectionModal.tsx @@ -1,16 +1,8 @@ import { Button } from "@superset/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "renderer/components/ui/dialog"; import { ScrollArea } from "@superset/ui/scroll-area"; +import { Download, FolderOpen, Settings, Terminal } from "lucide-react"; +import { Dialog, DialogContent } from "renderer/components/ui/dialog"; import type { Workspace } from "shared/types"; -import { FolderOpen, Plus } from "lucide-react"; -import { useState } from "react"; interface WorkspaceSelectionModalProps { isOpen: boolean; @@ -25,109 +17,142 @@ export function WorkspaceSelectionModal({ onSelectWorkspace, onCreateWorkspace, }: WorkspaceSelectionModalProps) { - const [selectedWorkspaceId, setSelectedWorkspaceId] = useState( - null, - ); - - const handleSelect = () => { - if (selectedWorkspaceId) { - onSelectWorkspace(selectedWorkspaceId); - setSelectedWorkspaceId(null); - } + const handleSelect = (workspaceId: string) => { + onSelectWorkspace(workspaceId); }; - const handleCreate = () => { - setSelectedWorkspaceId(null); + const handleOpenProject = () => { onCreateWorkspace(); }; + // Show recent workspaces (limit to 5 for display) + const recentWorkspaces = workspaces.slice(0, 5); + return ( - {}}> + { }}> - - Select Workspace - - Choose an existing workspace or create a new one to get started - - +
+ {/* Header */} +
+
+ Select Workspace +
+ +
-
- {workspaces.length > 0 ? ( - <> - -
- {workspaces.map((workspace) => ( - + + + + +
+ + {/* Recent Projects Section */} + {recentWorkspaces.length > 0 && ( +
+
+

+ Recent projects +

+ {workspaces.length > 5 && ( + + )} +
+ + +
+ {recentWorkspaces.map((workspace) => ( + - ))} +
+ + ))} +
+
- - - ) : ( -
-
- -

- No workspaces yet. Create one to get started. -

-
+ )} + + {/* Empty State */} + {workspaces.length === 0 && ( +
+ +

+ No workspaces yet +

+

+ Click "Open project" to get started +

+
+ )}
- )} +
- - - - {workspaces.length > 0 && ( - - )} - ); } - diff --git a/apps/desktop/src/renderer/screens/main/hooks/useWorkspace.ts b/apps/desktop/src/renderer/screens/main/hooks/useWorkspace.ts index 78d96f48d30..26baa525db4 100644 --- a/apps/desktop/src/renderer/screens/main/hooks/useWorkspace.ts +++ b/apps/desktop/src/renderer/screens/main/hooks/useWorkspace.ts @@ -91,14 +91,20 @@ export function useWorkspace({ const allWorkspaces = await window.ipcRenderer.invoke("workspace-list"); setWorkspaces(allWorkspaces); + // Check if this window was restored from a previous session + const isRestored = await window.ipcRenderer.invoke( + "window-is-restored", + ); + // Check for window-specific workspace first const workspaceId = await window.ipcRenderer.invoke( "workspace-get-window-workspace-id", ); // If window doesn't have a workspace assigned, show selection modal - // (new windows start with null workspace and should prompt user) - if (!workspaceId) { + // BUT only if this is a NEW window (not restored) + // Restored windows without workspace should not show modal (user closed them) + if (!workspaceId && !isRestored) { setShowWorkspaceSelection(true); return; } @@ -127,8 +133,8 @@ export function useWorkspace({ setSelectedTabId?.(activeSelection.tabId); } } - } else { - // No workspace selected - show selection modal + } else if (!isRestored) { + // No workspace selected and not restored - show selection modal setShowWorkspaceSelection(true); } } catch (err) { diff --git a/apps/desktop/src/shared/ipc-channels.ts b/apps/desktop/src/shared/ipc-channels.ts index ea073489d09..ce6ea4661a5 100644 --- a/apps/desktop/src/shared/ipc-channels.ts +++ b/apps/desktop/src/shared/ipc-channels.ts @@ -1,470 +1,6 @@ /** - * Type-safe IPC channel definitions - * - * This file defines all IPC channels with their request/response types. - * Use these types in both main and renderer processes for type safety. + * Re-export all IPC channel definitions from the modular structure + * This maintains backward compatibility with existing imports */ +export * from "./ipc-channels/index"; -import type { - CreateTabInput, - CreateWorkspaceInput, - CreateWorktreeInput, - MosaicNode, - Tab, - UpdatePreviewTabInput, - UpdateWorkspaceInput, - Workspace, - Worktree, -} from "./types"; - -/** - * Standard response format for operations - */ -export interface IpcResponse { - success: boolean; - data?: T; - error?: string; -} - -/** - * Define all IPC channels with their request and response types - */ -export interface IpcChannels { - // Workspace operations - "workspace-list": { - request: void; - response: Workspace[]; - }; - "workspace-get": { - request: string; // workspace ID - response: Workspace | null; - }; - "workspace-create": { - request: CreateWorkspaceInput; - response: IpcResponse; - }; - "workspace-update": { - request: UpdateWorkspaceInput; - response: IpcResponse; - }; - "workspace-delete": { - request: { id: string; removeWorktree?: boolean }; - response: IpcResponse; - }; - "workspace-get-last-opened": { - request: void; - response: Workspace | null; - }; - "workspace-scan-worktrees": { - request: string; // workspace ID - response: { success: boolean; imported?: number; error?: string }; - }; - "workspace-get-active-selection": { - request: string; // workspace ID - response: { - worktreeId: string | null; - tabId: string | null; - } | null; - }; - "workspace-set-active-selection": { - request: { - workspaceId: string; - worktreeId: string | null; - tabId: string | null; - }; - response: boolean; - }; - "workspace-get-active-workspace-id": { - request: void; - response: string | null; - }; - "workspace-set-active-workspace-id": { - request: string; // workspace ID - response: boolean; - }; - "workspace-get-window-workspace-id": { - request: void; - response: string | null; - }; - "workspace-set-window-workspace-id": { - request: string | null; // workspace ID or null - response: boolean; - }; - "workspace-list-branches": { - request: string; // workspace ID - response: { branches: string[]; currentBranch: string | null }; - }; - - // Worktree operations - "worktree-create": { - request: CreateWorktreeInput; - response: { - success: boolean; - worktree?: Worktree; - setupResult?: import("./types").SetupResult; - error?: string; - }; - }; - "worktree-remove": { - request: { workspaceId: string; worktreeId: string }; - response: IpcResponse; - }; - "worktree-can-remove": { - request: { workspaceId: string; worktreeId: string }; - response: { - success: boolean; - canRemove?: boolean; - hasUncommittedChanges?: boolean; - error?: string; - }; - }; - "worktree-can-merge": { - request: { - workspaceId: string; - worktreeId: string; - targetWorktreeId?: string; - }; - response: { - canMerge: boolean; - reason?: string; - isActiveWorktree?: boolean; - hasUncommittedChanges?: boolean; - targetHasUncommittedChanges?: boolean; - sourceHasUncommittedChanges?: boolean; - }; - }; - "worktree-merge": { - request: { - workspaceId: string; - worktreeId: string; - targetWorktreeId?: string; - }; - response: IpcResponse; - }; - "worktree-get-path": { - request: { workspaceId: string; worktreeId: string }; - response: string | null; - }; - "worktree-check-settings": { - request: { workspaceId: string; worktreeId: string }; - response: { success: boolean; exists?: boolean; error?: string }; - }; - "worktree-open-settings": { - request: { - workspaceId: string; - worktreeId: string; - createIfMissing?: boolean; - }; - response: { success: boolean; created?: boolean; error?: string }; - }; - "worktree-get-git-status": { - request: { workspaceId: string; worktreeId: string }; - response: { - success: boolean; - status?: { - branch: string; - ahead: number; - behind: number; - files: { - staged: Array<{ path: string; status: string }>; - unstaged: Array<{ path: string; status: string }>; - untracked: Array<{ path: string }>; - }; - diffAgainstMain: string; - isMerging: boolean; - isRebasing: boolean; - conflictFiles: string[]; - }; - error?: string; - }; - }; - "worktree-update-description": { - request: { - workspaceId: string; - worktreeId: string; - description: string; - }; - response: IpcResponse; - }; - "worktree-get-git-diff": { - request: { workspaceId: string; worktreeId: string }; - response: { - success: boolean; - diff?: { - files: Array<{ - id: string; - fileName: string; - filePath: string; - status: "added" | "deleted" | "modified" | "renamed"; - oldPath?: string; - additions: number; - deletions: number; - changes: Array<{ - type: "added" | "removed" | "modified" | "unchanged"; - oldLineNumber: number | null; - newLineNumber: number | null; - content: string; - }>; - }>; - }; - error?: string; - }; - }; - "worktree-get-git-diff-file-list": { - request: { workspaceId: string; worktreeId: string }; - response: { - success: boolean; - files?: Array<{ - id: string; - fileName: string; - filePath: string; - status: "added" | "deleted" | "modified" | "renamed"; - oldPath?: string; - additions: number; - deletions: number; - }>; - error?: string; - }; - }; - "worktree-get-git-diff-file": { - request: { - workspaceId: string; - worktreeId: string; - filePath: string; - oldPath?: string; - status: "added" | "deleted" | "modified" | "renamed"; - }; - response: { - success: boolean; - changes?: Array<{ - type: "added" | "removed" | "modified" | "unchanged"; - oldLineNumber: number | null; - newLineNumber: number | null; - content: string; - }>; - error?: string; - }; - }; - "worktree-create-pr": { - request: { workspaceId: string; worktreeId: string }; - response: { - success: boolean; - prUrl?: string; - error?: string; - }; - }; - "worktree-merge-pr": { - request: { workspaceId: string; worktreeId: string }; - response: { - success: boolean; - error?: string; - }; - }; - - // Tab operations - "tab-create": { - request: CreateTabInput; - response: { success: boolean; tab?: Tab; error?: string }; - }; - "tab-update-preview": { - request: UpdatePreviewTabInput; - response: IpcResponse; - }; - "tab-delete": { - request: { - workspaceId: string; - worktreeId: string; - tabId: string; - }; - response: IpcResponse; - }; - "tab-reorder": { - request: { - workspaceId: string; - worktreeId: string; - parentTabId?: string; // Optional parent tab ID (for reordering within a group) - tabIds: string[]; - }; - response: IpcResponse; - }; - "tab-move": { - request: { - workspaceId: string; - worktreeId: string; - tabId: string; - sourceParentTabId?: string; // Optional source parent tab ID - targetParentTabId?: string; // Optional target parent tab ID - targetIndex: number; - }; - response: IpcResponse; - }; - "tab-update-mosaic-tree": { - request: { - workspaceId: string; - worktreeId: string; - tabId: string; // The group tab ID - mosaicTree: MosaicNode | null | undefined; - }; - response: IpcResponse; - }; - "tab-update-name": { - request: { - workspaceId: string; - worktreeId: string; - tabId: string; - name: string; - }; - response: IpcResponse; - }; - - // Terminal operations - "terminal-create": { - request: { - id?: string; - cols?: number; - rows?: number; - cwd?: string; - }; - response: string; // terminal ID - }; - "terminal-execute-command": { - request: { id: string; command: string }; - response: void; - }; - "terminal-get-history": { - request: string; // terminal ID - response: string | undefined; - }; - "terminal-resize": { - request: { id: string; cols: number; rows: number; seq: number }; - response: void; - }; - "terminal-signal": { - request: { id: string; signal: string }; - response: void; - }; - "terminal-detach": { - request: string; // terminal ID - response: void; - }; - - // Update terminal CWD in workspace config - "workspace-update-terminal-cwd": { - request: { - workspaceId: string; - worktreeId: string; - tabId: string; - cwd: string; - }; - response: boolean; - }; - - // External operations - "open-external": { - request: string; // URL - response: void; - }; - "open-app-settings": { - request: void; - response: { success: boolean; error?: string }; - }; - - // Port detection and proxy operations - "workspace-set-ports": { - request: { - workspaceId: string; - ports: Array; - }; - response: IpcResponse; - }; - "workspace-get-detected-ports": { - request: { worktreeId: string }; - response: Record; - }; - "proxy-get-status": { - request: void; - response: Array<{ - canonical: number; - target?: number; - service?: string; - active: boolean; - }>; - }; - - // Deep linking - "deep-link-get-url": { - request: void; - response: string | null; - }; -} - -/** - * Type-safe IPC channel names - */ -export type IpcChannelName = keyof IpcChannels; - -/** - * Get request type for a channel - */ -export type IpcRequest = IpcChannels[T]["request"]; - -/** - * Get response type for a channel - */ -export type IpcResponse_ = IpcChannels[T]["response"]; - -/** - * Type guard to check if a channel name is valid - */ -export function isValidChannel(channel: string): channel is IpcChannelName { - const validChannels: IpcChannelName[] = [ - "workspace-list", - "workspace-get", - "workspace-create", - "workspace-update", - "workspace-delete", - "workspace-get-last-opened", - "workspace-scan-worktrees", - "workspace-get-active-selection", - "workspace-set-active-selection", - "workspace-get-active-workspace-id", - "workspace-set-active-workspace-id", - "workspace-get-window-workspace-id", - "workspace-set-window-workspace-id", - "workspace-list-branches", - "workspace-update-terminal-cwd", - "worktree-create", - "worktree-remove", - "worktree-can-remove", - "worktree-can-merge", - "worktree-merge", - "worktree-get-path", - "worktree-check-settings", - "worktree-open-settings", - "worktree-get-git-status", - "worktree-update-description", - "worktree-get-git-diff", - "worktree-create-pr", - "worktree-merge-pr", - "open-app-settings", - "tab-create", - "tab-update-preview", - "tab-delete", - "tab-reorder", - "tab-move", - "tab-update-mosaic-tree", - "tab-update-name", - "terminal-create", - "terminal-execute-command", - "terminal-get-history", - "terminal-resize", - "terminal-signal", - "terminal-detach", - "open-external", - "workspace-set-ports", - "workspace-get-detected-ports", - "proxy-get-status", - "deep-link-get-url", - ]; - return validChannels.includes(channel as IpcChannelName); -} diff --git a/apps/desktop/src/shared/ipc-channels/deep-link.ts b/apps/desktop/src/shared/ipc-channels/deep-link.ts new file mode 100644 index 00000000000..5fae8cb605f --- /dev/null +++ b/apps/desktop/src/shared/ipc-channels/deep-link.ts @@ -0,0 +1,13 @@ +/** + * Deep linking IPC channels + */ + +import type { NoRequest } from "./types"; + +export interface DeepLinkChannels { + "deep-link-get-url": { + request: NoRequest; + response: string | null; + }; +} + diff --git a/apps/desktop/src/shared/ipc-channels/external.ts b/apps/desktop/src/shared/ipc-channels/external.ts new file mode 100644 index 00000000000..b49ad5551ff --- /dev/null +++ b/apps/desktop/src/shared/ipc-channels/external.ts @@ -0,0 +1,18 @@ +/** + * External operations IPC channels + */ + +import type { NoRequest, NoResponse, SuccessResponse } from "./types"; + +export interface ExternalChannels { + "open-external": { + request: string; + response: NoResponse; + }; + + "open-app-settings": { + request: NoRequest; + response: SuccessResponse; + }; +} + diff --git a/apps/desktop/src/shared/ipc-channels/index.ts b/apps/desktop/src/shared/ipc-channels/index.ts new file mode 100644 index 00000000000..905df5c1233 --- /dev/null +++ b/apps/desktop/src/shared/ipc-channels/index.ts @@ -0,0 +1,71 @@ +/** + * Type-safe IPC channel definitions + * + * This file combines all IPC channel definitions from domain-specific modules. + * Use these types in both main and renderer processes for type safety. + */ + +import type { DeepLinkChannels } from "./deep-link"; +import type { ExternalChannels } from "./external"; +import type { ProxyChannels } from "./proxy"; +import type { TabChannels } from "./tab"; +import type { TerminalChannels } from "./terminal"; +import type { WindowChannels } from "./window"; +import type { WorktreeChannels } from "./worktree"; +import type { WorkspaceChannels } from "./workspace"; + +// Re-export shared types +export type { + IpcResponse, + NoRequest, + NoResponse, + SuccessResponse, +} from "./types"; + +/** + * Combine all channel definitions into a single interface + */ +export interface IpcChannels + extends WorkspaceChannels, + WorktreeChannels, + TabChannels, + TerminalChannels, + ProxyChannels, + ExternalChannels, + DeepLinkChannels, + WindowChannels {} + +/** + * Type-safe IPC channel names + */ +export type IpcChannelName = keyof IpcChannels; + +/** + * Get request type for a channel + */ +export type IpcRequest = IpcChannels[T]["request"]; + +/** + * Get response type for a channel + */ +export type IpcResponse_ = IpcChannels[T]["response"]; + +/** + * Type guard to check if a channel name is valid + * Auto-generated from IpcChannels interface to prevent drift + */ +export function isValidChannel(channel: string): channel is IpcChannelName { + // Auto-generate valid channels from the interface keys + // This ensures the list stays in sync with IpcChannels + const validChannels = Object.keys({} as IpcChannels) as IpcChannelName[]; + return validChannels.includes(channel as IpcChannelName); +} + +/** + * Get all valid channel names + * Useful for debugging and validation + */ +export function getAllChannelNames(): IpcChannelName[] { + return Object.keys({} as IpcChannels) as IpcChannelName[]; +} + diff --git a/apps/desktop/src/shared/ipc-channels/proxy.ts b/apps/desktop/src/shared/ipc-channels/proxy.ts new file mode 100644 index 00000000000..210a14cc51f --- /dev/null +++ b/apps/desktop/src/shared/ipc-channels/proxy.ts @@ -0,0 +1,18 @@ +/** + * Proxy-related IPC channels + */ + +import type { NoRequest } from "./types"; + +export interface ProxyChannels { + "proxy-get-status": { + request: NoRequest; + response: Array<{ + canonical: number; + target?: number; + service?: string; + active: boolean; + }>; + }; +} + diff --git a/apps/desktop/src/shared/ipc-channels/tab.ts b/apps/desktop/src/shared/ipc-channels/tab.ts new file mode 100644 index 00000000000..123198b347e --- /dev/null +++ b/apps/desktop/src/shared/ipc-channels/tab.ts @@ -0,0 +1,70 @@ +/** + * Tab-related IPC channels + */ + +import type { CreateTabInput, MosaicNode, Tab, UpdatePreviewTabInput } from "../types"; +import type { IpcResponse } from "./types"; + +export interface TabChannels { + "tab-create": { + request: CreateTabInput; + response: { success: boolean; tab?: Tab; error?: string }; + }; + + "tab-delete": { + request: { + workspaceId: string; + worktreeId: string; + tabId: string; + }; + response: IpcResponse; + }; + + "tab-update-preview": { + request: UpdatePreviewTabInput; + response: IpcResponse; + }; + + "tab-update-name": { + request: { + workspaceId: string; + worktreeId: string; + tabId: string; + name: string; + }; + response: IpcResponse; + }; + + "tab-reorder": { + request: { + workspaceId: string; + worktreeId: string; + parentTabId?: string; + tabIds: string[]; + }; + response: IpcResponse; + }; + + "tab-move": { + request: { + workspaceId: string; + worktreeId: string; + tabId: string; + sourceParentTabId?: string; + targetParentTabId?: string; + targetIndex: number; + }; + response: IpcResponse; + }; + + "tab-update-mosaic-tree": { + request: { + workspaceId: string; + worktreeId: string; + tabId: string; + mosaicTree: MosaicNode | null | undefined; + }; + response: IpcResponse; + }; +} + diff --git a/apps/desktop/src/shared/ipc-channels/terminal.ts b/apps/desktop/src/shared/ipc-channels/terminal.ts new file mode 100644 index 00000000000..d9de741fc4b --- /dev/null +++ b/apps/desktop/src/shared/ipc-channels/terminal.ts @@ -0,0 +1,43 @@ +/** + * Terminal-related IPC channels + */ + +import type { NoRequest, NoResponse } from "./types"; + +export interface TerminalChannels { + "terminal-create": { + request: { + id?: string; + cols?: number; + rows?: number; + cwd?: string; + }; + response: string; + }; + + "terminal-execute-command": { + request: { id: string; command: string }; + response: NoResponse; + }; + + "terminal-get-history": { + request: string; + response: string | undefined; + }; + + "terminal-resize": { + request: { id: string; cols: number; rows: number; seq: number }; + response: NoResponse; + }; + + "terminal-signal": { + request: { id: string; signal: string }; + response: NoResponse; + }; + + "terminal-detach": { + request: string; + response: NoResponse; + }; +} + diff --git a/apps/desktop/src/shared/ipc-channels/types.ts b/apps/desktop/src/shared/ipc-channels/types.ts new file mode 100644 index 00000000000..fefb420203b --- /dev/null +++ b/apps/desktop/src/shared/ipc-channels/types.ts @@ -0,0 +1,28 @@ +/** + * Shared types for IPC channel definitions + */ + +/** + * Standard response format for operations + */ +export interface IpcResponse { + success: boolean; + data?: T; + error?: string; +} + +/** + * Helper type for channels with no request data + */ +export type NoRequest = void; + +/** + * Helper type for channels with no response data + */ +export type NoResponse = void; + +/** + * Helper type for simple success/error responses + */ +export type SuccessResponse = { success: boolean; error?: string }; + diff --git a/apps/desktop/src/shared/ipc-channels/window.ts b/apps/desktop/src/shared/ipc-channels/window.ts new file mode 100644 index 00000000000..a386207bffc --- /dev/null +++ b/apps/desktop/src/shared/ipc-channels/window.ts @@ -0,0 +1,18 @@ +/** + * Window-related IPC channels + */ + +import type { NoRequest, SuccessResponse } from "./types"; + +export interface WindowChannels { + "window-create": { + request: NoRequest; + response: SuccessResponse; + }; + + "window-is-restored": { + request: NoRequest; + response: boolean; + }; +} + diff --git a/apps/desktop/src/shared/ipc-channels/workspace.ts b/apps/desktop/src/shared/ipc-channels/workspace.ts new file mode 100644 index 00000000000..4d7f68a1c15 --- /dev/null +++ b/apps/desktop/src/shared/ipc-channels/workspace.ts @@ -0,0 +1,114 @@ +/** + * Workspace-related IPC channels + */ + +import type { + CreateWorkspaceInput, + UpdateWorkspaceInput, + Workspace, +} from "../types"; +import type { IpcResponse, NoRequest } from "./types"; + +export interface WorkspaceChannels { + "workspace-list": { + request: NoRequest; + response: Workspace[]; + }; + + "workspace-get": { + request: string; + response: Workspace | null; + }; + + "workspace-create": { + request: CreateWorkspaceInput; + response: IpcResponse; + }; + + "workspace-update": { + request: UpdateWorkspaceInput; + response: IpcResponse; + }; + + "workspace-delete": { + request: { id: string; removeWorktree?: boolean }; + response: IpcResponse; + }; + + "workspace-get-last-opened": { + request: NoRequest; + response: Workspace | null; + }; + + "workspace-scan-worktrees": { + request: string; + response: { success: boolean; imported?: number; error?: string }; + }; + + "workspace-list-branches": { + request: string; + response: { branches: string[]; currentBranch: string | null }; + }; + + "workspace-set-ports": { + request: { + workspaceId: string; + ports: Array; + }; + response: IpcResponse; + }; + + "workspace-get-detected-ports": { + request: { worktreeId: string }; + response: Record; + }; + + "workspace-update-terminal-cwd": { + request: { + workspaceId: string; + worktreeId: string; + tabId: string; + cwd: string; + }; + response: boolean; + }; + + // Workspace Selection & State + "workspace-get-active-selection": { + request: string; + response: { + worktreeId: string | null; + tabId: string | null; + } | null; + }; + + "workspace-set-active-selection": { + request: { + workspaceId: string; + worktreeId: string | null; + tabId: string | null; + }; + response: boolean; + }; + + "workspace-get-active-workspace-id": { + request: NoRequest; + response: string | null; + }; + + "workspace-set-active-workspace-id": { + request: string; + response: boolean; + }; + + "workspace-get-window-workspace-id": { + request: NoRequest; + response: string | null; + }; + + "workspace-set-window-workspace-id": { + request: string | null; + response: boolean; + }; +} + diff --git a/apps/desktop/src/shared/ipc-channels/worktree.ts b/apps/desktop/src/shared/ipc-channels/worktree.ts new file mode 100644 index 00000000000..58c09838e1a --- /dev/null +++ b/apps/desktop/src/shared/ipc-channels/worktree.ts @@ -0,0 +1,185 @@ +/** + * Worktree-related IPC channels + */ + +import type { CreateWorktreeInput, Worktree } from "../types"; +import type { IpcResponse, SuccessResponse } from "./types"; + +export interface WorktreeChannels { + "worktree-create": { + request: CreateWorktreeInput; + response: { + success: boolean; + worktree?: Worktree; + setupResult?: import("../types").SetupResult; + error?: string; + }; + }; + + "worktree-remove": { + request: { workspaceId: string; worktreeId: string }; + response: IpcResponse; + }; + + "worktree-get-path": { + request: { workspaceId: string; worktreeId: string }; + response: string | null; + }; + + "worktree-update-description": { + request: { + workspaceId: string; + worktreeId: string; + description: string; + }; + response: IpcResponse; + }; + + // Worktree Merge Operations + "worktree-can-merge": { + request: { + workspaceId: string; + worktreeId: string; + targetWorktreeId?: string; + }; + response: { + canMerge: boolean; + reason?: string; + isActiveWorktree?: boolean; + hasUncommittedChanges?: boolean; + targetHasUncommittedChanges?: boolean; + sourceHasUncommittedChanges?: boolean; + }; + }; + + "worktree-merge": { + request: { + workspaceId: string; + worktreeId: string; + targetWorktreeId?: string; + }; + response: IpcResponse; + }; + + "worktree-can-remove": { + request: { workspaceId: string; worktreeId: string }; + response: { + success: boolean; + canRemove?: boolean; + hasUncommittedChanges?: boolean; + error?: string; + }; + }; + + // Worktree Settings + "worktree-check-settings": { + request: { workspaceId: string; worktreeId: string }; + response: { success: boolean; exists?: boolean; error?: string }; + }; + + "worktree-open-settings": { + request: { + workspaceId: string; + worktreeId: string; + createIfMissing?: boolean; + }; + response: { success: boolean; created?: boolean; error?: string }; + }; + + // Worktree Git Operations + "worktree-get-git-status": { + request: { workspaceId: string; worktreeId: string }; + response: { + success: boolean; + status?: { + branch: string; + ahead: number; + behind: number; + files: { + staged: Array<{ path: string; status: string }>; + unstaged: Array<{ path: string; status: string }>; + untracked: Array<{ path: string }>; + }; + diffAgainstMain: string; + isMerging: boolean; + isRebasing: boolean; + conflictFiles: string[]; + }; + error?: string; + }; + }; + + "worktree-get-git-diff": { + request: { workspaceId: string; worktreeId: string }; + response: { + success: boolean; + diff?: { + files: Array<{ + id: string; + fileName: string; + filePath: string; + status: "added" | "deleted" | "modified" | "renamed"; + oldPath?: string; + additions: number; + deletions: number; + changes: Array<{ + type: "added" | "removed" | "modified" | "unchanged"; + oldLineNumber: number | null; + newLineNumber: number | null; + content: string; + }>; + }>; + }; + error?: string; + }; + }; + + "worktree-get-git-diff-file-list": { + request: { workspaceId: string; worktreeId: string }; + response: { + success: boolean; + files?: Array<{ + id: string; + fileName: string; + filePath: string; + status: "added" | "deleted" | "modified" | "renamed"; + oldPath?: string; + additions: number; + deletions: number; + }>; + error?: string; + }; + }; + + "worktree-get-git-diff-file": { + request: { + workspaceId: string; + worktreeId: string; + filePath: string; + oldPath?: string; + status: "added" | "deleted" | "modified" | "renamed"; + }; + response: { + success: boolean; + changes?: Array<{ + type: "added" | "removed" | "modified" | "unchanged"; + oldLineNumber: number | null; + newLineNumber: number | null; + content: string; + }>; + error?: string; + }; + }; + + // Worktree PR Operations + "worktree-create-pr": { + request: { workspaceId: string; worktreeId: string }; + response: { success: boolean; prUrl?: string; error?: string }; + }; + + "worktree-merge-pr": { + request: { workspaceId: string; worktreeId: string }; + response: SuccessResponse; + }; +} +