Skip to content

Commit 30ecde6

Browse files
authored
refactor: move file-watcher to trpc, add event emitter patern (#277)
Add a typed event emitter, use it in the new file watcher service
1 parent 5ba1bd0 commit 30ecde6

File tree

16 files changed

+481
-361
lines changed

16 files changed

+481
-361
lines changed

ARCHITECTURE.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,83 @@ This pattern provides:
308308
5. **Add router** to `src/main/trpc/router.ts`
309309
6. **Use in renderer** via `trpcReact` hooks
310310

311+
## Events (tRPC Subscriptions)
312+
313+
For pushing real-time updates from main to renderer, use tRPC subscriptions with typed event emitters.
314+
315+
### 1. Define Events in schemas.ts
316+
317+
Use a const object for event names and an interface for payloads:
318+
319+
```typescript
320+
// src/main/services/my-service/schemas.ts
321+
export const MyServiceEvent = {
322+
ItemCreated: "item-created",
323+
ItemDeleted: "item-deleted",
324+
} as const;
325+
326+
export interface MyServiceEvents {
327+
[MyServiceEvent.ItemCreated]: { id: string; name: string };
328+
[MyServiceEvent.ItemDeleted]: { id: string };
329+
}
330+
```
331+
332+
### 2. Extend TypedEventEmitter in Service
333+
334+
```typescript
335+
// src/main/services/my-service/service.ts
336+
import { TypedEventEmitter } from "../../lib/typed-event-emitter";
337+
import { MyServiceEvent, type MyServiceEvents } from "./schemas";
338+
339+
@injectable()
340+
export class MyService extends TypedEventEmitter<MyServiceEvents> {
341+
async createItem(name: string) {
342+
const item = { id: "123", name };
343+
// TypeScript enforces correct event name and payload shape
344+
this.emit(MyServiceEvent.ItemCreated, item);
345+
return item;
346+
}
347+
}
348+
```
349+
350+
### 3. Create Subscriptions in Router
351+
352+
Use a helper to reduce boilerplate:
353+
354+
```typescript
355+
// src/main/trpc/routers/my-router.ts
356+
import { on } from "node:events";
357+
import { MyServiceEvent, type MyServiceEvents } from "../../services/my-service/schemas";
358+
359+
function subscribe<K extends keyof MyServiceEvents>(event: K) {
360+
return publicProcedure.subscription(async function* (opts) {
361+
const service = getService();
362+
for await (const [payload] of on(service, event, { signal: opts.signal })) {
363+
yield payload as MyServiceEvents[K];
364+
}
365+
});
366+
}
367+
368+
export const myRouter = router({
369+
// ... queries and mutations
370+
onItemCreated: subscribe(MyServiceEvent.ItemCreated),
371+
onItemDeleted: subscribe(MyServiceEvent.ItemDeleted),
372+
});
373+
```
374+
375+
### 4. Subscribe in Renderer
376+
377+
```typescript
378+
// React component
379+
trpcReact.my.onItemCreated.useSubscription(undefined, {
380+
enabled: true,
381+
onData: (item) => {
382+
// item is typed as { id: string; name: string }
383+
console.log("Created:", item);
384+
},
385+
});
386+
```
387+
311388
## Code Style
312389

313390
See [CLAUDE.md](./CLAUDE.md) for linting, formatting, and import conventions.

apps/array/src/main/di/container.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Container } from "inversify";
33
import { ContextMenuService } from "../services/context-menu/service.js";
44
import { DockBadgeService } from "../services/dock-badge/service.js";
55
import { ExternalAppsService } from "../services/external-apps/service.js";
6+
import { FileWatcherService } from "../services/file-watcher/service.js";
67
import { FsService } from "../services/fs/service.js";
78
import { GitService } from "../services/git/service.js";
89
import { MAIN_TOKENS } from "./tokens.js";
@@ -14,5 +15,6 @@ export const container = new Container({
1415
container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService);
1516
container.bind(MAIN_TOKENS.DockBadgeService).to(DockBadgeService);
1617
container.bind(MAIN_TOKENS.ExternalAppsService).to(ExternalAppsService);
18+
container.bind(MAIN_TOKENS.FileWatcherService).to(FileWatcherService);
1719
container.bind(MAIN_TOKENS.FsService).to(FsService);
1820
container.bind(MAIN_TOKENS.GitService).to(GitService);

apps/array/src/main/di/tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const MAIN_TOKENS = Object.freeze({
99
ContextMenuService: Symbol.for("Main.ContextMenuService"),
1010
DockBadgeService: Symbol.for("Main.DockBadgeService"),
1111
ExternalAppsService: Symbol.for("Main.ExternalAppsService"),
12+
FileWatcherService: Symbol.for("Main.FileWatcherService"),
1213
FsService: Symbol.for("Main.FsService"),
1314
GitService: Symbol.for("Main.GitService"),
1415
});

apps/array/src/main/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import { trpcRouter } from "./trpc/index.js";
3333
// Legacy type kept for backwards compatibility with taskControllers map
3434
type TaskController = unknown;
3535

36-
import { registerFileWatcherIpc } from "./services/fileWatcher.js";
3736
import { registerFoldersIpc } from "./services/folders.js";
3837
import { registerGitIpc } from "./services/git.js";
3938
import "./services/index.js";
@@ -298,7 +297,6 @@ ipcMain.handle("app:get-version", () => app.getVersion());
298297
registerOAuthHandlers();
299298
registerGitIpc();
300299
registerAgentIpc(taskControllers, () => mainWindow);
301-
registerFileWatcherIpc(() => mainWindow);
302300
registerFoldersIpc(() => mainWindow);
303301
registerWorktreeIpc();
304302
registerShellIpc();
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { EventEmitter } from "node:events";
2+
3+
export class TypedEventEmitter<TEvents> extends EventEmitter {
4+
emit<K extends keyof TEvents & string>(
5+
event: K,
6+
payload: TEvents[K],
7+
): boolean {
8+
return super.emit(event, payload);
9+
}
10+
11+
on<K extends keyof TEvents & string>(
12+
event: K,
13+
listener: (payload: TEvents[K]) => void,
14+
): this {
15+
return super.on(event, listener);
16+
}
17+
18+
off<K extends keyof TEvents & string>(
19+
event: K,
20+
listener: (payload: TEvents[K]) => void,
21+
): this {
22+
return super.off(event, listener);
23+
}
24+
}

apps/array/src/main/preload.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -173,27 +173,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
173173
defaultBranch: string;
174174
compareUrl: string | null;
175175
} | null> => ipcRenderer.invoke("get-git-repo-info", repoPath),
176-
listDirectory: (
177-
dirPath: string,
178-
): Promise<
179-
Array<{ name: string; path: string; type: "file" | "directory" }>
180-
> => ipcRenderer.invoke("fs:list-directory", dirPath),
181-
watcherStart: (repoPath: string): Promise<void> =>
182-
ipcRenderer.invoke("watcher:start", repoPath),
183-
watcherStop: (repoPath: string): Promise<void> =>
184-
ipcRenderer.invoke("watcher:stop", repoPath),
185-
onDirectoryChanged: (
186-
listener: IpcEventListener<{ repoPath: string; dirPath: string }>,
187-
): (() => void) => createIpcListener("fs:directory-changed", listener),
188-
onFileChanged: (
189-
listener: IpcEventListener<{ repoPath: string; filePath: string }>,
190-
): (() => void) => createIpcListener("fs:file-changed", listener),
191-
onFileDeleted: (
192-
listener: IpcEventListener<{ repoPath: string; filePath: string }>,
193-
): (() => void) => createIpcListener("fs:file-deleted", listener),
194-
onGitStateChanged: (
195-
listener: IpcEventListener<{ repoPath: string }>,
196-
): (() => void) => createIpcListener("git:state-changed", listener),
197176
onOpenSettings: (listener: () => void): (() => void) =>
198177
createVoidIpcListener("open-settings", listener),
199178
onNewTask: (listener: () => void): (() => void) =>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { z } from "zod";
2+
3+
export const listDirectoryInput = z.object({
4+
dirPath: z.string(),
5+
});
6+
7+
export const watcherInput = z.object({
8+
repoPath: z.string(),
9+
});
10+
11+
const directoryEntry = z.object({
12+
name: z.string(),
13+
path: z.string(),
14+
type: z.enum(["file", "directory"]),
15+
});
16+
17+
export const listDirectoryOutput = z.array(directoryEntry);
18+
19+
export type ListDirectoryInput = z.infer<typeof listDirectoryInput>;
20+
export type WatcherInput = z.infer<typeof watcherInput>;
21+
export type DirectoryEntry = z.infer<typeof directoryEntry>;
22+
23+
export const FileWatcherEvent = {
24+
DirectoryChanged: "directory-changed",
25+
FileChanged: "file-changed",
26+
FileDeleted: "file-deleted",
27+
GitStateChanged: "git-state-changed",
28+
} as const;
29+
30+
export interface FileWatcherEvents {
31+
[FileWatcherEvent.DirectoryChanged]: { repoPath: string; dirPath: string };
32+
[FileWatcherEvent.FileChanged]: { repoPath: string; filePath: string };
33+
[FileWatcherEvent.FileDeleted]: { repoPath: string; filePath: string };
34+
[FileWatcherEvent.GitStateChanged]: { repoPath: string };
35+
}

0 commit comments

Comments
 (0)