Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# AGENTS.md

## Package Manager — Critical

**Use `yarn` only. Never `npm`.** The README explicitly warns against it.

- Yarn v4 (Berry), binary checked in at `.yarn/releases/yarn-4.12.0.cjs`
- Node.js v24 (pinned in `.nvmrc`)
- Install: `yarn install`

## Dev Commands

| Command | Purpose |
|---|---|
| `yarn start` | Dev mode (Electron + Vite HMR) |
| `yarn test` | Run all tests (Vitest) |
| `yarn lint` | ESLint |
| `yarn prettier` | Auto-format (run before committing) |
| `yarn prettier-check` | Check formatting (CI gate) |
| `yarn make` | Build distributable for current OS |

Run a single test file:
```bash
yarn vitest run src/path/to/file.test.ts
# filter by name:
yarn vitest run --reporter=verbose -t "pattern"
```

There is no standalone `typecheck` script — TypeScript is checked during Vite builds.

## Architecture: Three Process Areas

Source is split into three Vite sub-projects, each with its own `vite.config.ts`:

- **`src/main/`** — Electron main process (Node.js). Entry: `main.ts`. Network, filesystem, IPC, scripting, persistence.
- **`src/renderer/`** — React UI (browser). Entry: `index.tsx`. Zustand+Immer state, Radix UI, Monaco editor.
- **`src/shim/`** — Shared types/interfaces used by both main and renderer (IPC contracts, scripting API types).

Path alias `@/*` → `src/renderer/*`; `shim` → `src/shim/`.

Module system is `nodenext` — use `.js` extensions in imports where required by TypeScript.

## Generated Code

**`src/renderer/assets/trufos-scripting-api.d.ts` is auto-generated — do not edit.**

- Generated by the `generateAssets` hook in `forge.config.ts` (runs on `yarn start` and `yarn make`)
- Source of truth: `src/shim/scripting.ts` (`GlobalScriptingApi` interface)

## Testing

Vitest v4 with a `projects` config running all three sub-projects from the root `vitest.config.ts`. No external services required.

**Main process tests** (`environment: node`):
- Mocks: `electron`, `node:fs`, `node:fs/promises`, `tmp` auto-applied via `src/main/__mocks__/index.ts`
- Filesystem mocked with `memfs` (`vol`); `vol.reset()` called `beforeEach`
- Custom matchers: `toBeOfSchema(zodSchema)` and `toBeOfSchemaAsync(zodSchema)`

**Renderer tests** (`environment: jsdom`):
- Setup: `src/renderer/test-setup.ts`; uses `@testing-library/react`
- `monaco-editor` aliased to ESM API entry in Vitest config
- `enableMapSet()` from Immer called globally

## CI Gates (PRs will fail if violated)

1. `yarn prettier-check` — always run `yarn prettier` before committing
2. `yarn test`
3. PR title must follow Conventional Commits; branch must match `(feature|feat|fix|chore|refactor)/.*`
4. Issues must be linked unless branch starts with `chore/`

## Commit & Branch Conventions

- Commits: English, present tense, format `#<issue-id> - <message>` (explain *why*)
- Branches: `<type>/<issue-id>-<branch-name>` (type: `feature`, `fix`, `chore`)
- Merge strategy: merge commits (no fast-forward); rebase branch onto `main` before PR merge

## Code Style

Prettier config (`.prettierrc`): `singleQuote`, `semi`, `tabWidth: 2`, `printWidth: 100`, `trailingComma: 'es5'`, TailwindCSS class sorting plugin.
9 changes: 9 additions & 0 deletions src/main/environment/service/environment-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ export class EnvironmentService implements Initializable {
this.currentCollection.clientCertificate = clientCertificate ?? undefined;
}

/**
* Reloads the current collection from the file system, updating the in-memory state.
*/
public async reloadCurrentCollection() {
this._currentCollection = await persistenceService.loadCollection(
this._currentCollection.dirPath
);
}

/**
* Loads the given collection and sets it as the current collection.
*
Expand Down
15 changes: 9 additions & 6 deletions src/main/event/main-event-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,17 @@ describe('MainEventService', () => {
ReturnType<typeof import('./main-event-service')>
>['MainEventService'];
let PersistenceService: Awaited<
ReturnType<typeof import('../persistence/service/persistence-service')>
ReturnType<typeof import('main/persistence/service/persistence-service')>
>['PersistenceService'];
let EnvironmentService: Awaited<
ReturnType<typeof import('main/environment/service/environment-service')>
>['EnvironmentService'];

beforeEach(async () => {
fs.writeFileSync(TEST_FILE_PATH, TEST_STRING);
({ MainEventService } = await import('./main-event-service'));
({ PersistenceService } = await import('../persistence/service/persistence-service'));
({ PersistenceService } = await import('main/persistence/service/persistence-service'));
({ EnvironmentService } = await import('main/environment/service/environment-service'));
});

it('should register event functions on the backend', async () => {
Expand All @@ -64,6 +68,7 @@ describe('MainEventService', () => {
const reorderItemSpy = vi
.spyOn(PersistenceService.instance, 'reorderItem')
.mockResolvedValue(collection);
vi.spyOn(EnvironmentService.instance, 'reloadCurrentCollection').mockResolvedValue(undefined);

const eventService = new MainEventService();
await eventService.reorderItem(collection, 'child-id', 0);
Expand Down Expand Up @@ -108,6 +113,7 @@ describe('MainEventService', () => {
const moveChildSpy = vi
.spyOn(PersistenceService.instance, 'moveChild')
.mockResolvedValue(undefined);
vi.spyOn(EnvironmentService.instance, 'reloadCurrentCollection').mockResolvedValue(undefined);

const eventService = new MainEventService();
await eventService.moveItem(request, collection, folder, 0);
Expand Down Expand Up @@ -213,10 +219,7 @@ describe('MainEventService', () => {
await Promise.resolve(); // flush microtask queue for the async .then() chain

expect(saveCollectionSpy).toHaveBeenCalledWith(collection);
expect(webContentsSend).toHaveBeenCalledWith('collection-variables-updated', {
variables: collection.variables,
environments: collection.environments,
});
expect(webContentsSend).toHaveBeenCalledWith('collection-updated', collection);
});
});
});
36 changes: 29 additions & 7 deletions src/main/event/main-event-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ function toError(error: unknown) {
return new Error(error?.toString());
}

/**
* Pushes the current in-memory collection to the renderer so it can sync its state
* without an extra round-trip.
*/
function pushCollectionUpdate(webContents: WebContents | null) {
webContents?.send('collection-updated', environmentService.currentCollection);
}

/**
* Service for handling events on the main process coming from the renderer process.
*/
Expand All @@ -80,12 +88,9 @@ export class MainEventService implements IEventService {
registerEvent(this, propertyName as keyof MainEventService);
}
ScriptingService.instance.on('variables-changed', () => {
const { variables, environments } = environmentService.currentCollection;
void persistenceService
.saveCollection(environmentService.currentCollection)
.then(() =>
this.webContents?.send('collection-variables-updated', { variables, environments })
)
.then(() => pushCollectionUpdate(this.webContents))
.catch((err) => logger.error('Failed to persist variable changes', err));
});
logger.debug('Registered event channels on backend');
Expand Down Expand Up @@ -117,7 +122,10 @@ export class MainEventService implements IEventService {
}

async copyRequest(request: TrufosRequest): Promise<TrufosRequest> {
return await persistenceService.copyRequest(request);
const result = await persistenceService.copyRequest(request);
await environmentService.reloadCurrentCollection();
pushCollectionUpdate(this.webContents);
return result;
}

async saveChanges(request: TrufosRequest) {
Expand All @@ -134,6 +142,8 @@ export class MainEventService implements IEventService {

async deleteObject(object: TrufosObject) {
await persistenceService.delete(object);
await environmentService.reloadCurrentCollection();
pushCollectionUpdate(this.webContents);
}

async getActiveEnvironmentVariables() {
Expand Down Expand Up @@ -165,10 +175,15 @@ export class MainEventService implements IEventService {

async saveFolder(folder: Folder) {
await persistenceService.saveFolder(folder);
await environmentService.reloadCurrentCollection();
pushCollectionUpdate(this.webContents);
}

async copyFolder(folder: Folder): Promise<Folder> {
return await persistenceService.copyFolder(folder);
const result = await persistenceService.copyFolder(folder);
await environmentService.reloadCurrentCollection();
pushCollectionUpdate(this.webContents);
return result;
}

async openCollection(dirPath: string) {
Expand Down Expand Up @@ -205,18 +220,25 @@ export class MainEventService implements IEventService {
position?: number
) {
await persistenceService.moveChild(child, oldParent, newParent, position);
await environmentService.reloadCurrentCollection();
pushCollectionUpdate(this.webContents);
}

async reorderItem<T extends Folder | Collection>(
parent: T,
childId: string,
newIndex: number
): Promise<T> {
return await persistenceService.reorderItem(parent, childId, newIndex);
const result = await persistenceService.reorderItem(parent, childId, newIndex);
await environmentService.reloadCurrentCollection();
pushCollectionUpdate(this.webContents);
return result;
}

async rename(object: TrufosObject, newTitle: string): Promise<void> {
await persistenceService.rename(object, newTitle);
await environmentService.reloadCurrentCollection();
pushCollectionUpdate(this.webContents);
}

updateApp() {
Expand Down
10 changes: 8 additions & 2 deletions src/renderer/services/event/renderer-event-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { MainProcessError } from '@/error/MainProcessError';
import { IpcRendererEvent } from 'electron';
import { Collection } from 'shim';
import { IEventService } from 'shim/event-service';

/**
Expand All @@ -24,6 +26,10 @@ function createEventMethod<T extends keyof IEventService>(methodName: T) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface RendererEventService {
on(event: 'before-close', listener: () => void): this;
on(
event: 'collection-updated',
listener: (ipcEvent: IpcRendererEvent, collection: Collection) => void
): this;

emit(event: 'ready-to-close'): this;
}
Expand All @@ -32,12 +38,12 @@ export interface RendererEventService {
export class RendererEventService implements IEventService {
public static readonly instance = new RendererEventService();

on(event: string, listener: (...args: unknown[]) => void) {
on(event: string, listener: (...args: any[]) => void) {
window.electron.ipcRenderer.on(event, listener);
return this;
}

emit(event: string, ...args: unknown[]) {
emit(event: string, ...args: any[]) {
window.electron.ipcRenderer.send(event, ...args);
return this;
}
Expand Down
20 changes: 15 additions & 5 deletions src/renderer/state/CollectionStoreProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,22 +51,32 @@ beforeEach(() => {
});

describe('CollectionStoreProvider', () => {
it('updates variableStore and environmentStore when collection-variables-updated is received', async () => {
it('updates variableStore and environmentStore when collection-updated is received', async () => {
render(
<CollectionStoreProvider>
<div />
</CollectionStoreProvider>
);

await waitFor(() => expect(listeners['collection-variables-updated']).toBeDefined());
await waitFor(() => expect(listeners['collection-updated']).toBeDefined());

const testVariables = { apiKey: { value: 'test-123' } };
const testEnvironments = { dev: { variables: { host: { value: 'localhost' } } } };

act(() => {
listeners['collection-variables-updated'](
listeners['collection-updated'](
{}, // ipcRendererEvent (ignored)
{ variables: testVariables, environments: testEnvironments }
{
id: 'col-1',
type: 'collection',
title: 'Test',
dirPath: '/test',
children: [],
variables: testVariables,
environments: testEnvironments,
lastModified: 0,
isDefault: false,
}
);
});

Expand All @@ -81,7 +91,7 @@ describe('CollectionStoreProvider', () => {
</CollectionStoreProvider>
);

await waitFor(() => expect(listeners['collection-variables-updated']).toBeDefined());
await waitFor(() => expect(listeners['collection-updated']).toBeDefined());

expect(useVariableStore.getState().variables).toEqual({});
expect(useEnvironmentStore.getState().environments).toEqual({});
Expand Down
14 changes: 4 additions & 10 deletions src/renderer/state/CollectionStoreProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import {
} from '@/state/collectionStore';
import { REQUEST_MODEL, SCRIPT_MODEL } from '@/lib/monaco/models';
import { showError } from '@/error/errorHandler';
import { useVariableStore } from '@/state/variableStore';
import { useEnvironmentStore } from '@/state/environmentStore';

const rendererEventService = RendererEventService.instance;

Expand All @@ -25,14 +23,10 @@ export const CollectionStoreProvider: FC<PropsWithChildren> = ({ children }) =>
// Create store with loaded collection
storeRef.current = createCollectionStore(collection);

// Sync variables changed by scripts back to the frontend stores
rendererEventService.on(
'collection-variables-updated',
(_ipcEvent, { variables, environments }) => {
useVariableStore.getState().initialize(variables);
useEnvironmentStore.getState().initialize(environments);
}
);
// Sync structural collection changes (including variable/environment updates) pushed from main
rendererEventService.on('collection-updated', (_ipcEvent, updatedCollection) => {
storeRef.current?.getState().initialize(updatedCollection);
});

// Set up before-close handler with access to store instance
rendererEventService.on('before-close', async () => {
Expand Down
Loading