# IPC Communication Guide

This guide explains how the renderer communicates with the main process in Broomy through Electron's IPC system, mediated by a preload script that exposes typed APIs on the `window` object.

## Architecture Overview

Broomy uses Electron's context-isolated IPC with three layers:

1. **Main process** (`src/main/handlers/`) -- handlers are organized into domain-specific modules (e.g., `gitBasic.ts`, `pty.ts`, `config.ts`) with a central registration entry point (`src/main/handlers/index.ts`).
2. **Preload script** (`src/preload/index.ts`) -- re-exports types and wires API implementations from `src/preload/apis/` (separate modules for each domain: `pty.ts`, `fs.ts`, `git.ts`, `gh.ts`, `config.ts`, `shell.ts`, `menu.ts`) to `contextBridge.exposeInMainWorld()`.
3. **Renderer** (`src/renderer/`) -- calls `window.pty.*`, `window.git.*`, `window.fs.*`, etc. as regular async functions.

```
Renderer (React)          Preload                    Main Process
─────────────────         ──────────────             ──────────────
window.git.push(path) --> ipcRenderer.invoke() ----> ipcMain.handle()
                          'git:push'                 simpleGit(path).push()
                     <--- returns Promise        <--- returns result
```

## Pattern 1: Request/Response

The primary pattern. The renderer calls a method, the main process handles it and returns a result.

**Main process handler:**

```ts
// src/main/index.ts
ipcMain.handle('git:stage', async (_event, repoPath: string, filePath: string) => {
  if (isE2ETest) { return { success: true } }
  try {
    const git = simpleGit(repoPath)
    await git.add([filePath])
    return { success: true }
  } catch (error) {
    return { success: false, error: String(error) }
  }
})
```

**Preload wiring:**

```ts
// src/preload/index.ts
export type GitApi = {
  stage: (repoPath: string, filePath: string) => Promise<{ success: boolean; error?: string }>
}
const gitApi: GitApi = {
  stage: (repoPath, filePath) => ipcRenderer.invoke('git:stage', repoPath, filePath),
}
contextBridge.exposeInMainWorld('git', gitApi)
```

**Renderer usage:**

```ts
const result = await window.git.stage('/path/to/repo', 'src/index.ts')
if (!result.success) console.error('Stage failed:', result.error)
```

## Pattern 2: Event Streaming

For continuous data flows (terminal output, file system changes), the main process pushes events to the renderer. Events are namespaced by ID so multiple PTY processes or file watchers can coexist.

**Main process pushes data:**

```ts
// src/main/index.ts -- PTY output forwarding
ptyProcess.onData((data) => {
  const ownerWindow = ptyOwnerWindows.get(options.id) || mainWindow
  if (ownerWindow && !ownerWindow.isDestroyed()) {
    ownerWindow.webContents.send(`pty:data:${options.id}`, data)
  }
})
```

**Preload registers listeners that return cleanup functions:**

```ts
// src/preload/index.ts
onData: (id, callback) => {
  const handler = (_event: Electron.IpcRendererEvent, data: string) => callback(data)
  ipcRenderer.on(`pty:data:${id}`, handler)
  return () => ipcRenderer.removeListener(`pty:data:${id}`, handler)
},
```

**File watcher streaming uses the same pattern:**

```ts
// Main process sends: webContents.send(`fs:change:${id}`, { eventType, filename })
// Preload listens:    ipcRenderer.on(`fs:change:${id}`, handler)
```

## Namespace Convention

| Channel pattern | Direction | Purpose |
|---|---|---|
| `pty:data:${id}` | main --> renderer | Terminal output bytes |
| `pty:exit:${id}` | main --> renderer | Terminal process exited |
| `fs:change:${id}` | main --> renderer | File/directory changed |

The `id` is generated by the renderer (typically `${sessionId}-${Date.now()}`) and passed to the main process when creating the resource.

## The Window Type Declaration

The preload declares a global `Window` interface so TypeScript knows about all APIs:

```ts
declare global {
  interface Window {
    pty: PtyApi;  dialog: DialogApi;  fs: FsApi;  git: GitApi
    config: ConfigApi;  app: AppApi;  menu: MenuApi;  gh: GhApi
    repos: ReposApi;  shell: ShellApi;  profiles: ProfilesApi
    agents: AgentsApi;  ts: TsApi
  }
}
```

## API Groups

| API | Channel prefix | Purpose |
|---|---|---|
| `window.pty` | `pty:*` | Create, write to, resize, and kill PTY processes |
| `window.fs` | `fs:*` | Read/write files, list directories, watch for changes |
| `window.git` | `git:*` | Branch info, status, staging, commits, diffs, worktrees |
| `window.gh` | `gh:*` | GitHub CLI wrappers (issues, PRs, reviews) |
| `window.config` | `config:*` | Load/save per-profile configuration |
| `window.profiles` | `profiles:*` | Multi-window profile management |
| `window.shell` | `shell:*` | Execute shell commands, open external URLs |
| `window.app` | `app:*` | Environment info (isDev, homedir, platform) |
| `window.menu` | `menu:*` | Native context menus |
| `window.dialog` | `dialog:*` | Native file/folder picker dialogs |
| `window.agents` | `agent:*` | Check if an agent CLI is installed |
| `window.repos` | `repos:*` | Per-repo init scripts |
| `window.ts` | `ts:*` | TypeScript project context collection |

## Error Handling Convention

Fallible operations return `{ success: true }` or `{ success: false, error: String(error) }`. Errors are always stringified with `String(error)` for safe IPC serialization.

Some handlers return data directly (e.g., `git:getBranch` returns a string). These return a fallback value on error instead of using the result wrapper.

## E2E Test Mocking

Every handler checks `isE2ETest` at the top and returns deterministic mock data:

```ts
const isE2ETest = process.env.E2E_TEST === 'true'

ipcMain.handle('git:isGitRepo', async (_event, dirPath: string) => {
  if (isE2ETest) { return true }
  // ... real implementation
})
```

This ensures Playwright tests never touch real repos, APIs, or config files.

## How to Add a New IPC Handler

### Step 1: Main process handler

Add a handler in the appropriate file under `src/main/handlers/`. Handlers are organized by domain (e.g., git handlers in `gitBasic.ts`, `gitBranch.ts`, `gitSync.ts`; composed in `git.ts`). Each handler module exports a `register` function that receives `ipcMain` and a shared `HandlerContext`:

```ts
// src/main/handlers/myNamespace.ts
import { IpcMain } from 'electron'
import { HandlerContext } from './types'

async function handleMyAction(ctx: HandlerContext, arg1: string) {
  if (ctx.isE2ETest) { return { success: true, data: 'mock-value' } }
  try {
    const result = await doSomething(arg1)
    return { success: true, data: result }
  } catch (error) {
    return { success: false, error: String(error) }
  }
}

export function register(ipcMain: IpcMain, ctx: HandlerContext): void {
  ipcMain.handle('myNamespace:myAction', (_event, arg1: string) => handleMyAction(ctx, arg1))
}
```

### Step 2: Preload type and wiring

Add a new API module in `src/preload/apis/`, then import and expose it in `src/preload/index.ts`:

```ts
// src/preload/apis/myNamespace.ts
import { ipcRenderer } from 'electron'

export type MyNamespaceApi = {
  myAction: (arg1: string) => Promise<{ success: boolean; data?: string; error?: string }>
}

export const myNamespaceApi: MyNamespaceApi = {
  myAction: (arg1) => ipcRenderer.invoke('myNamespace:myAction', arg1),
}
```

```ts
// src/preload/index.ts -- add imports and expose
import type { MyNamespaceApi } from './apis/myNamespace'
import { myNamespaceApi } from './apis/myNamespace'

contextBridge.exposeInMainWorld('myNamespace', myNamespaceApi)

// Add to Window declaration
declare global {
  interface Window { myNamespace: MyNamespaceApi }
}
```

### Step 3: Renderer usage

```ts
const result = await window.myNamespace.myAction('hello')
```

### Step 4: Unit test mock

```ts
// src/test/setup.ts
const mockMyNamespace = {
  myAction: vi.fn().mockResolvedValue({ success: true, data: 'mock-value' }),
}
// Add to broomyMocks object
```

### Step 5: Test

- **Unit tests**: Use `vi.mocked(window.myNamespace.myAction).mockResolvedValue(...)` to customize per test.
- **E2E tests**: The handler already returns mock data when `E2E_TEST=true`.

## Key Implementation Details

**Window ownership tracking** -- Each PTY and file watcher tracks which `BrowserWindow` created it. Events are sent only to the owning window, which is critical for multi-window profile support:

```ts
const senderWindow = BrowserWindow.fromWebContents(_event.sender)
ptyOwnerWindows.set(options.id, senderWindow)
// Later: ownerWindow = ptyOwnerWindows.get(options.id) || mainWindow
```

**Cleanup on window close** -- When a window closes, all PTY processes and file watchers are killed automatically.

**Listener cleanup pattern** -- Event-streaming APIs return an unsubscribe function. Always call it in React `useEffect` cleanup:

```ts
useEffect(() => {
  const removeDataListener = window.pty.onData(id, (data) => terminal.write(data))
  const removeExitListener = window.pty.onExit(id, (code) => { /* ... */ })
  return () => { removeDataListener(); removeExitListener() }
}, [id])
```
