Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 9ce8756

Browse files
charlesvienjonathanlab
authored andcommitted
refactor: port focus to core and workspace server
1 parent 08bff6e commit 9ce8756

21 files changed

Lines changed: 1488 additions & 1034 deletions

File tree

MIGRATION.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ For the procedure to follow when porting a new feature, see [REFACTOR.md](./REFA
2929

3030
---
3131

32+
## 2026-05-28 — focus (core owns orchestration, workspace-server owns host/git work)
33+
34+
- Moved host operations out of Electron main: `apps/code/src/main/services/focus/sync-service.ts` deleted; git/worktree/watch logic now lives in `packages/workspace-server/src/services/focus/{service,sync-service}.ts` behind one-line `focus.*` procedures in `packages/workspace-server/src/trpc.ts`.
35+
- Moved orchestration out of the renderer: `apps/code/src/renderer/stores/sagas/focusSagas.ts` deleted; multi-step enable/disable/restore flow now lives in `packages/core/src/focus/service.ts` as `FocusController`, with dependencies injected as a pure interface.
36+
- Renderer stays thin: `apps/code/src/renderer/stores/focusStore.ts` is now UI state plus one controller call per action. It adapts existing tRPC calls into the core dependency interface and no longer owns the flow graph.
37+
- Main is a bridge, not the source of truth for focus logic: `apps/code/src/main/services/focus/service.ts` now persists the local session snapshot for Electron restarts, forwards mutations/queries to workspace-server through `WorkspaceClient`, and re-emits focus events to legacy main-router subscribers.
38+
- Bridge retirement: delete the main `FocusService` shim and move persisted focus-session storage out of Electron once session restore/event subscribers can read directly from workspace-server (or the eventual shared persistence layer). At that point the main `focus` router can disappear with the bridge.
39+
- Left as-is: restore still re-saves the validated session before starting workspace-server watchers so the server-side in-memory session map is repopulated after app restart. That is intentional coexistence glue, not the final architecture.
40+
41+
---
42+
3243
## 2026-05-27 — diff-stats
3344

3445
- Moved: `apps/code/src/main/services/git/getDiffStats``packages/workspace-server/src/services/git/service.ts` + `packages/ui/src/features/diff-stats/`

REFACTOR.md

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ The desktop **main process is not the home of business logic anymore.** It does
9292
- **Source-smoothing belongs with the source, not in core.** Debouncing a noisy event stream, dedup, bulk-threshold throttling, filtering source-specific noise (irrelevant git dir events, etc.) — these are properties of the *event source*, not domain decisions. They live in the workspace-server procedure that owns the source, so every client gets the smoothed stream for free. Don't put them in core just because they look like "orchestration."
9393
- **Hooks are pure react-query idioms.** `useQuery`, `useMutation`, `useSubscription` over a tRPC procedure — that's the whole hook. No `useEffect` constructing services. No `for-await` over async iterables in a hook body. No imperative subscribe/unsubscribe ceremony with a wrappers map. If you find yourself reaching for those, the orchestration is in the wrong place — push it to wherever the tRPC procedure lives (typically workspace-server) and the hook collapses to 5 lines.
9494
- **`useState`, `useRef`, `useEffect` in a hook are usually a smell.** They mean the hook is holding application state or subscription bookkeeping that should live elsewhere — react-query's cache, a Zustand store, a workspace-server procedure, or just derivation from existing query data. The legitimate uses are narrow: `useRef` for DOM refs (focus, scroll, measurement), `useEffect` for synchronizing imperative browser APIs (event listeners on `window`, `ResizeObserver`, etc.). Anything else — caching a previous value, holding a subscription handle, stashing a callback ref to avoid re-renders, building a wrappers map — means the hook is doing work that belongs upstream.
95+
- **Try framework primitives before reaching for core.** Before extracting a forbidden pattern into a new core module, ask: does react-query / tRPC / Zustand already do this? `useMutation` dedups by mutation key. `useQuery` dedups by query key. `useSubscription` handles lifecycle. tRPC subscriptions invalidate caches. Most "I need a state machine for this" cases dissolve into a single mutation + its `onSuccess`. **Delete the forbidden pattern and use the framework primitive** is the first move. Only reach for a core module when you can't express the orchestration as a mutation/query/subscription — typically a Saga (multi-step with rollback), a long-running protocol (OAuth dance with redirects), or coordination that crosses multiple queries with invariants.
96+
- **Smallest change first.** Try deleting the offending code before introducing a new abstraction. Try moving side effects into an existing `onSuccess` before writing an event bus. Try inlining at the call site before extracting a helper. The refactor PR should land *less* code than it deletes whenever possible. If your change adds a net new package, a new singleton, or a new abstraction layer, justify the line count.
97+
- **Validate the app actually runs.** Typecheck and tests pass on incomplete work all the time. For any user-visible change, open the app and exercise the feature. For background changes, watch logs through one real usage cycle. CI green ≠ feature works.
98+
- **Some main services stay in main forever.** Single-instance lock, window manager, deep-link router, crash reporter, auto-updater, app-lifecycle, anything that *is* the Electron shell. Don't try to migrate these. Mark them explicitly as "host-only" in code comments or a service-categorization doc so nobody wastes time auditing them for a slice.
9599

96100
## Comment markers
97101

@@ -115,9 +119,12 @@ Use these consistently. Grep targets matter — follow-up passes hunt for each m
115119
| `apps/code/src/renderer/features/<X>/` (UI) | `packages/ui/<X>/` |
116120
| `apps/code/src/renderer/stores/<X>.ts` (thin UI state) | `packages/ui/<X>/store.ts` (still Zustand, still thin) |
117121
| `apps/code/src/main/platform-adapters/<X>.ts` | `apps/desktop/platform-adapters/<X>.ts` |
122+
| Renderer-consumed host capability (auth, notifications, integrations — anything in main that the renderer needs to query/mutate via electron-trpc) | `packages/platform/src/<capability>.ts` interface + `apps/code/src/renderer/platform-adapters/<capability>.ts` adapter that wraps `trpcClient.X.*` |
118123

119124
If the migrated feature is pure data-piping (server → useQuery → component), there's no row to core — that's expected, not a missed step.
120125

126+
**Platform adapters apply in both directions.** The existing 15 interfaces in `packages/platform/src/` are all main-process-consumed (main service calls `IClipboard.write`). The same pattern works for renderer-consumed capabilities: interface in `packages/platform/`, adapter in `apps/<host>/src/<process>/platform-adapters/`, ui/core consume via the interface. This is the path for features that live in main and need to be reachable from ui — there's no separate "electron-trpc-client" package needed; the adapter IS the bridge.
127+
121128
---
122129

123130
## Per-feature procedure
@@ -139,6 +146,64 @@ Do these in order. One feature at a time.
139146

140147
---
141148

149+
## Canonical shape for features with real orchestration
150+
151+
When a feature genuinely needs core (multi-step Saga, OAuth dance, cross-query invariants — not just "we already had a forbidden pattern there"), use this shape. The **focus** port is the worked example.
152+
153+
```
154+
packages/core/src/<feature>/<feature>.ts
155+
└─ export interface <Feature>ControllerDeps { ... } // narrow, feature-scoped
156+
└─ export class <Feature>Controller {
157+
constructor(private deps: <Feature>ControllerDeps, ...) {}
158+
async enableX(input): Promise<XResult> // methods DO things, RETURN results
159+
async disableX(input): Promise<XResult> // no internal state held about the domain
160+
}
161+
162+
apps/code/src/renderer/stores/<feature>Store.ts
163+
└─ const controller = new <Feature>Controller({ // module-scope singleton, OK because stateless
164+
methodA: (...) => trpcClient.X.a.mutate(...), // each dep: one-line trpc wrap
165+
methodB: (...) => trpcClient.X.b.query(...),
166+
...
167+
}, logger);
168+
└─ export const use<Feature>Store = create<...>()((set, get) => ({
169+
session: null, // pure UI state
170+
isLoading: false,
171+
enableX: async (input) => {
172+
set({ isLoading: true });
173+
const result = await controller.enableX(input);
174+
set({ isLoading: false, session: result.success ? result.session : get().session });
175+
return result;
176+
},
177+
// ... thin actions: call controller, set state from result
178+
}));
179+
```
180+
181+
**Why this shape:**
182+
183+
- **Controller is stateless.** It orchestrates. Domain state lives where react can render it (store / react-query cache). The controller never holds `this.session` or `this.user` — those would be a second source of truth.
184+
- **Module-scope `new Controller(...)` is fine** because the controller is stateless and its deps are trpc-bound (which is also a singleton). The forbidden "store owning a singleton with state" pattern doesn't apply.
185+
- **Deps are feature-scoped, defined in core.** Not a global platform interface, not a re-export from the trpc client. ~20-30 narrow methods the controller actually uses. The renderer adapter is dumb one-line wraps over `trpcClient.X`.
186+
- **Store actions are call-controller-then-set.** No multi-step flow in the store. No `let inFlight` dedup. No cross-store reach-ins (those move to the controller, or to mutation `onSuccess` if simple).
187+
- **No event bus.** State changes via store updates after each action returns. React-query consumers react via cache invalidation (the store action can invalidate after success).
188+
189+
**When this shape applies:**
190+
191+
The feature has at least one of:
192+
- A Saga (multi-step with rollback) — e.g., focus enable: stash, checkout, save session, on failure unstash and restore
193+
- A long-running protocol — OAuth dance with redirects, multi-round handshake
194+
- An invariant that spans multiple queries — e.g., "if A is true, B must also be refreshed"
195+
- A state machine genuinely complex enough that expressing it as one mutation `onSuccess` is hostile
196+
197+
If none of those apply — if the orchestration is "call endpoint, set state from result" — the feature **doesn't need core**. Use `useMutation`/`useQuery` directly. Don't invent a controller for symmetry.
198+
199+
**When this shape does NOT apply:**
200+
201+
- Pure data-piping (server query → useQuery → render). No core. The hook is 5 lines of `useQuery` over the tRPC procedure.
202+
- Source-smoothing (debounce, dedup of noisy events). Goes in the workspace-server procedure that owns the source, not in core.
203+
- Plain auth state that's already served by `trpc.X.getState`. React-query's cache IS the state. Don't shadow it with a stateful core class.
204+
205+
---
206+
142207
## Coexistence and bridges
143208

144209
This codebase is heavily inter-coupled — most main-process services consume events from, or call methods on, other main-process services. A pure "one feature, one slice, delete the old" port is the exception, not the rule. Expect coexistence; design for it.
@@ -221,13 +286,20 @@ If you find debt that isn't a forbidden pattern and isn't a layering fix, **leav
221286

222287
## Recommended order
223288

224-
1. **Read-only, no subscriptions.** Done — diff-stats.
225-
2. **Read-only, subscription-based** — done. file-watcher proved the SSE streaming transport (workspace-client `splitLink` + `httpSubscriptionLink`, hono server accepting `?secret=` query).
226-
3. **Auth / api-client-adjacent.** Exercises the api-client path end-to-end. Next.
227-
4. **Write paths** (focus mode, worktree ops).
289+
1. **Read-only, no subscriptions** — done. diff-stats.
290+
2. **Read-only, subscription-based** — done. file-watcher proved the SSE streaming transport (workspace-client `splitLink` + `httpSubscriptionLink`, hono server accepting `?secret=` query). Source-smoothing lives in workspace-server, hook is pure `useSubscription`.
291+
3. **Write paths with Saga orchestration** — done. focus proved the [canonical core-bearing shape](#canonical-shape-for-features-with-real-orchestration): stateless `FocusController` in core with feature-scoped deps interface, thin store wraps `trpcClient.X.*` as deps adapter, store actions call controller and set state from result. This is the reference for any future feature that genuinely needs core.
292+
4. **Renderer-side platform adapter** — next. Auth or notifications. Establishes the pattern for the ~25 host-capability services to follow: `packages/platform/src/<cap>.ts` interface + `apps/code/src/renderer/platform-adapters/<cap>.ts` adapter wrapping `trpcClient.X.*` + ui consumes via context. Unlocks the bulk of the remaining main services.
228293
5. **Terminal / pty proxying.** Most ambitious. Tests the full pipeline including binary data.
229294

230-
The first two slices also surfaced two recurring patterns now baked into the ground rules: source-smoothing belongs with the source (not core), and hooks are pure react-query idioms (not useEffect wrappers). Apply them on every slice going forward.
295+
Patterns now baked into the ground rules from prior slices:
296+
- Source-smoothing belongs with the source (not core) — file-watcher.
297+
- Hooks are pure react-query idioms — file-watcher.
298+
- Stateless controller + thin store + dumb deps adapter for features that need core — focus.
299+
- Try framework primitives before reaching for core; most "I need a state machine" cases dissolve into `useMutation` + `onSuccess`.
300+
- Platform adapters apply in both directions; the existing 15 are main-consumed, the next ones are renderer-consumed.
301+
302+
Apply these on every slice going forward.
231303

232304
---
233305

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@ import { DeepLinkService } from "../services/deep-link/service";
3838
import { EnrichmentService } from "../services/enrichment/service";
3939
import { EnvironmentService } from "../services/environment/service";
4040
import { ExternalAppsService } from "../services/external-apps/service";
41-
import { FocusService } from "../services/focus/service";
42-
import { FocusSyncService } from "../services/focus/sync-service";
4341
import { FoldersService } from "../services/folders/service";
4442
import { FsService } from "../services/fs/service";
4543
import { GitService } from "../services/git/service";
@@ -124,8 +122,6 @@ container.bind(MAIN_TOKENS.ProvisioningService).to(ProvisioningService);
124122
container.bind(MAIN_TOKENS.ExternalAppsService).to(ExternalAppsService);
125123
container.bind(MAIN_TOKENS.LlmGatewayService).to(LlmGatewayService);
126124
container.bind(MAIN_TOKENS.McpAppsService).to(McpAppsService);
127-
container.bind(MAIN_TOKENS.FocusService).to(FocusService);
128-
container.bind(MAIN_TOKENS.FocusSyncService).to(FocusSyncService);
129125
container.bind(MAIN_TOKENS.FoldersService).to(FoldersService);
130126
container.bind(MAIN_TOKENS.FsService).to(FsService);
131127
container

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ export const MAIN_TOKENS = Object.freeze({
5656
McpAppsService: Symbol.for("Main.McpAppsService"),
5757
FileWatcherService: Symbol.for("Main.FileWatcherService"),
5858
FocusService: Symbol.for("Main.FocusService"),
59-
FocusSyncService: Symbol.for("Main.FocusSyncService"),
6059
FoldersService: Symbol.for("Main.FoldersService"),
6160
FsService: Symbol.for("Main.FsService"),
6261
GitService: Symbol.for("Main.GitService"),

apps/code/src/main/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createWorkspaceClient } from "@posthog/workspace-client/client";
44
import { app, BrowserWindow, dialog } from "electron";
55
import log from "electron-log/main";
66
import { FileWatcherBridge } from "./services/file-watcher/bridge";
7+
import { FocusService } from "./services/focus/service";
78
import "./utils/logger";
89
import "./services/index.js";
910
import { ANALYTICS_EVENTS } from "@shared/types/analytics";
@@ -242,6 +243,9 @@ app.whenReady().then(async () => {
242243
container
243244
.bind(MAIN_TOKENS.FileWatcherService)
244245
.toConstantValue(new FileWatcherBridge(workspaceClient));
246+
container
247+
.bind(MAIN_TOKENS.FocusService)
248+
.toConstantValue(new FocusService(workspaceClient));
245249

246250
await initializeServices();
247251
initializeDeepLinks();

0 commit comments

Comments
 (0)