Gitify is an Electron menubar app that monitors notifications across supported Git forges. This document describes how the code is organised so the structure stays coherent as the app grows. It is the reference for where new code belongs.
Gitify runs as three Electron processes plus a small shared module:
| Location | Role |
|---|---|
src/main/ |
Electron main process: window/menubar lifecycle, tray, handlers, updater |
src/preload/ |
The only bridge between renderer and main, exposed as a typed window.gitify |
src/shared/ |
Code imported by both processes (constants, events, logger, platform, theme) |
src/renderer/ |
The React application |
The renderer never reaches into the main process directly: it goes through window.gitify. A capability that requires Node or Electron APIs belongs in the main process behind an IPC handler.
The renderer is layered. Runtime imports flow in one direction only — a module may import from its own layer or any layer below it, never above. Type-only imports are exempt: importing types never creates a runtime dependency, so it need not follow the direction.
types / constants / icons (leaf: shared types, values, presentational icons)
|
+----+----+
| |
utils stores peer domain layers
(domain (client state,
logic) Zustand)
| |
+----+----+
|
hooks orchestration over stores, query, and utils
|
components presentational React components
|
routes screens
|
App.tsx / index.tsx composition root - may import any layer
utils/is split by concern:api,auth,core,forges,notifications,system,ui.types.ts,constants.ts, andicons/are the leaf modules: shared types, shared values, and presentational icons.types.tsimports types fromutils/auth/typesand generated GitHub GraphQL types — those imports are type-only, so the direction rule does not apply.icons/sits belowutils, so bothutilsandcomponentsmay import it (a forge adapter declares its brand mark from here).stores/holds client state (Zustand): persistedaccountsandsettings, sessionfiltersand notification action failures.utilsandstoresare peers — they may reference each other. Neither may importhooks,components, orroutes.components/is grouped by feature (notifications,filters,settings,login,fields,avatars,layout,metrics,primitives).- The composition root (
App.tsx,index.tsx) is the only place allowed to wire every layer together.
Every kind of renderer state has exactly one owner. Put new state with the mechanism that owns its kind:
| Kind of state | Owner | Examples |
|---|---|---|
| Server / async data | TanStack Query | the notification list, periodic account refreshes |
| Persisted client state | Zustand with persist |
accounts, settings |
| Session client state | Zustand without persist |
filters, notification action failures |
| Derived values | A store selector/action, or a pure utils/ function |
isLoggedIn, hasMultipleAccounts, notification status |
| Ephemeral view state | React useState / useMemo |
row expand/collapse, exit animations |
Rules:
- Application state is never owned by a React context.
AppContextwas retired in favour of hooks and stores; do not reintroduce a context provider to hold app state. - Derived values are not stored. If a value can be computed from existing state, expose it as a selector or a pure function instead of adding new state. Derived predicates are read reactively (invoke the store accessor inside the selector) so consumers update when their source changes.
- Query state is server state: never mirror it into a store.
App-level side effects are owned by a single host. GlobalEffects mounts exactly once (inside the query and theme providers) and owns theme application, tray updates, global shortcut registration, periodic account refreshes, and notification polling and alerts.
Consumers read the shared query cache and stores, and do not register their own polling, alerts, wake, or focus listeners. This keeps background work (for example, firing one poll per interval) from multiplying with the number of mounted components. When you add a new background effect, add it to the host, not to a leaf component.
Multi-forge support is adapter-based. Each forge implements the ForgeAdapter interface in src/renderer/utils/forges/, and shared code routes through getAdapter(forge) (forge-wide members) or getAccountAdapter(account) (account-scoped operations). Shared code never imports forge-specific modules directly, and there is no forge-specific branching outside an adapter module. See CONTRIBUTING.md for the conditions for adding a forge.
The one-way module dependency direction is checked automatically: vite.config.mts defines per-layer no-restricted-imports overrides for the renderer, and pnpm check fails on a violation. The enforced rules are: utils and stores must not import hooks, components, or routes; hooks must not import components or routes; components must not import routes; and the leaf layers stay leaves — icons must not import any implementation layer, and the leaf modules types/constants must not import implementation layers either (their type-only imports are permitted). Any future exception must be argued and documented here rather than silently allow-listed.