Skip to content
Merged
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
2 changes: 2 additions & 0 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export default defineConfig({
allow: [resolve('.')]
}
},
// electron-vite leaves the renderer unminified: 3.9 MB vs 1.8 MB, ~60 ms later first paint.
build: { minify: true },
plugins: [react()]
}
});
265 changes: 252 additions & 13 deletions package-lock.json

Large diffs are not rendered by default.

22 changes: 10 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"test:watch": "vitest --project renderer --project main",
"test:e2e": "electron-vite build && playwright test -c e2e/playwright.config.ts"
},
"dependencies": {
"devDependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.4",
Expand All @@ -59,35 +59,33 @@
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.2.0",
"@mui/x-tree-view": "^9.15.0",
"@northern.tech/themes": "^0.17.0",
"@reduxjs/toolkit": "^2.12.0",
"@uiw/react-codemirror": "^4.25.11",
"@xyflow/react": "^12.12.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-redux": "^9.3.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@northern.tech/eslint-config": "^0.7.2",
"@northern.tech/prettier-config": "^0.2.1",
"@northern.tech/themes": "^0.17.0",
"@playwright/test": "^1.63.0",
"@reduxjs/toolkit": "^2.12.0",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.6.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@uiw/react-codemirror": "^4.25.11",
"@vitejs/plugin-react": "^5.2.0",
"@vitest/coverage-v8": "^5.0.3",
"@xyflow/react": "^12.12.0",
"electron": "^44.5.1",
"electron-builder": "^26.17.0",
"electron-vite": "^5.0.0",
"eslint": "^10.12.0",
"globals": "^17.13.0",
"jsdom": "^30.1.2",
"prettier": "^3.9.9",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-redux": "^9.3.0",
"typescript": "^6.0.3",
"vite": "^7.3.7",
"vitest": "^5.0.1"
"vitest": "^5.0.1",
"zod": "^4.6.5"
}
}
17 changes: 3 additions & 14 deletions src/main/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { randomBytes, timingSafeEqual } from 'crypto';
import { BrowserWindow, type IpcMainInvokeEvent, type WebContents, type WebFrameMain, app, ipcMain } from 'electron';
import { promises as fs } from 'fs';
import { type IncomingMessage, type Server, type ServerResponse, createServer } from 'http';
import type { AddressInfo } from 'net';
import { join } from 'path';

import { MCP_INSTRUCTIONS, registerBuilderTools } from './mcpTools/index';
import { type McpPermissions, checkedPermissions, isToolAllowed } from './mcpTools/permissions';

/**
Expand All @@ -18,6 +15,7 @@ import { type McpPermissions, checkedPermissions, isToolAllowed } from './mcpToo

const HOST = '127.0.0.1';
const TOOL_TIMEOUT_MS = 30_000;
const SERVER_NAME = 'cfengine-policy-builder';
// Tools that wait on cfbs, Docker or the agent: a test run builds, starts hosts and runs cf-agent.
const SLOW_TOOLS: Record<string, number> = {
create_project: 180_000,
Expand All @@ -27,7 +25,6 @@ const SLOW_TOOLS: Record<string, number> = {
run_tests: 900_000,
stop_test_environment: 300_000
};
const SERVER_NAME = 'cfengine-policy-builder';

interface McpSettings {
enabled: boolean;
Expand Down Expand Up @@ -137,16 +134,8 @@ async function handle(req: IncomingMessage, res: ServerResponse, port: number, t
res.writeHead(404).end();
return;
}
// Stateless: a server and transport per request.
const mcp = new McpServer({ name: SERVER_NAME, version: app.getVersion() }, { instructions: MCP_INSTRUCTIONS });
registerBuilderTools(mcp, callWindowTool, permissions);
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on('close', () => {
void transport.close();
void mcp.close();
});
await mcp.connect(transport);
await transport.handleRequest(req, res);
const { answerMcpRequest } = await import('./mcpTools/serve');
await answerMcpRequest(req, res, { name: SERVER_NAME, version: app.getVersion() }, callWindowTool, permissions);
}

function listen(port: number, token: string): Promise<number> {
Expand Down
27 changes: 27 additions & 0 deletions src/main/mcpTools/serve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import type { IncomingMessage, ServerResponse } from 'http';

import { MCP_INSTRUCTIONS, registerBuilderTools } from './index';
import type { McpPermissions } from './permissions';
import type { CallWindow } from './shared';

// Loaded on the first request (mcp.ts): the SDK and zod take ~300 ms, too much for every launch.
// Stateless: a server and transport per request.
export async function answerMcpRequest(
req: IncomingMessage,
res: ServerResponse,
info: { name: string; version: string },
call: CallWindow,
permissions: McpPermissions
) {
const mcp = new McpServer(info, { instructions: MCP_INSTRUCTIONS });
registerBuilderTools(mcp, call, permissions);
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on('close', () => {
void transport.close();
void mcp.close();
});
await mcp.connect(transport);
await transport.handleRequest(req, res);
}
32 changes: 32 additions & 0 deletions src/renderer/src/canvas/stableItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype;

// Equal down to `depth` levels of plain objects and arrays; any two functions count as equal.
export function sameShape(a: unknown, b: unknown, depth: number): boolean {
if (Object.is(a, b)) return true;
if (typeof a === 'function' && typeof b === 'function') return true;
if (depth === 0) return false;
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((item, index) => sameShape(item, b[index], depth - 1));
if (!isPlainObject(a) || !isPlainObject(b)) return false;
const keys = Object.keys(a);
return keys.length === Object.keys(b).length && keys.every(key => key in b && sameShape(a[key], b[key], depth - 1));
}

/**
* React Flow re-renders a node or edge whenever its object changes. These are rebuilt on every
* canvas render, so each is swapped for last call's object when nothing in it changed (three
* levels deep, callbacks ignored). The callbacks must only capture what the item itself holds,
* and reach the canvas's handlers through a latest-ref (useLatest), or a reused one goes stale.
* Create one per list and keep it for the component's life: `useState(() => stableItems())`.
*/
export function stableItems<T extends { id: string }>(): (items: T[]) => T[] {
let previous = new Map<string, T>();
return items => {
const stable = items.map(item => {
const before = previous.get(item.id);
return before && sameShape(before, item, 3) ? before : item;
});
previous = new Map(stable.map(item => [item.id, item]));
return stable;
};
}
45 changes: 23 additions & 22 deletions src/renderer/src/components/BlockGroupRow.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useState } from 'react';

import { Box, Button, Divider, ListItemText, Menu, MenuItem, Typography, useTheme } from '@mui/material';

import { useMenuAnchor } from '../hooks/useMenuAnchor';
import type { BlockInstance } from '../store/canvasSlice/types';
import type { BlockGroup } from '../store/groupsSlice/types';

Expand All @@ -23,7 +22,7 @@ export function BlockGroupRow({
onOpen: (groupId: string) => void;
}) {
const theme = useTheme();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const menu = useMenuAnchor();
const group = groups.find(candidate => candidate.id === instance.groupId);
const others = groups.filter(candidate => candidate.id !== instance.groupId);

Expand All @@ -41,33 +40,35 @@ export function BlockGroupRow({
</Button>
</>
) : (
<Button size="small" onClick={event => setAnchorEl(event.currentTarget)} sx={{ textTransform: 'none', fontSize: 12, px: 0.5 }}>
<Button size="small" onClick={event => menu.open(event.currentTarget)} sx={{ textTransform: 'none', fontSize: 12, px: 0.5 }}>
Add to group…
</Button>
)}
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)}>
{others.map(candidate => (
{menu.mounted && (
<Menu anchorEl={menu.anchor} open={Boolean(menu.anchor)} onClose={menu.close}>
{others.map(candidate => (
<MenuItem
key={candidate.id}
onClick={() => {
onJoin(candidate.id);
menu.close();
}}
>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: theme.palette[candidate.color].main, mr: 1 }} />
<ListItemText primary={candidate.name || 'Untitled group'} slotProps={{ primary: { sx: { fontSize: 13 } } }} />
</MenuItem>
))}
{others.length > 0 && <Divider />}
<MenuItem
key={candidate.id}
onClick={() => {
onJoin(candidate.id);
setAnchorEl(null);
onNewGroup();
menu.close();
}}
>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: theme.palette[candidate.color].main, mr: 1 }} />
<ListItemText primary={candidate.name || 'Untitled group'} slotProps={{ primary: { sx: { fontSize: 13 } } }} />
<ListItemText primary="New group with this block" slotProps={{ primary: { sx: { fontSize: 13 } } }} />
</MenuItem>
))}
{others.length > 0 && <Divider />}
<MenuItem
onClick={() => {
onNewGroup();
setAnchorEl(null);
}}
>
<ListItemText primary="New group with this block" slotProps={{ primary: { sx: { fontSize: 13 } } }} />
</MenuItem>
</Menu>
</Menu>
)}
</Box>
);
}
6 changes: 3 additions & 3 deletions src/renderer/src/components/BlockPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { memo, useMemo, useState } from 'react';

import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import SearchIcon from '@mui/icons-material/Search';
Expand Down Expand Up @@ -75,7 +75,7 @@ function CategoryGroup({ category, blocks, onAdd }: { blocks: BlockDescriptor[];
);
}

export function BlockPalette({ onAddBlock }: { onAddBlock: (block: BlockDescriptor) => void }) {
export const BlockPalette = memo(function BlockPalette({ onAddBlock }: { onAddBlock: (block: BlockDescriptor) => void }) {
const [search, setSearch] = useState('');

const groups = useMemo(() => {
Expand Down Expand Up @@ -119,4 +119,4 @@ export function BlockPalette({ onAddBlock }: { onAddBlock: (block: BlockDescript
</Box>
</Box>
);
}
});
37 changes: 20 additions & 17 deletions src/renderer/src/components/DataNode.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ReactNode, useMemo, useState } from 'react';
import { type ReactNode, useMemo } from 'react';

import CloseIcon from '@mui/icons-material/Close';
import DataObjectIcon from '@mui/icons-material/DataObject';
Expand All @@ -12,7 +12,9 @@ import { currentChainType, decoratorsById } from '../blocks/decorators';
import type { DecoratorValue } from '../blocks/evaluateDecorator';
import { runDecoratorChain } from '../blocks/runDecoratorChain';
import { DATA_NODE_WIDTH, type DataChain } from '../canvas/dataChains';
import { useMenuAnchor } from '../hooks/useMenuAnchor';
import { AddDecoratorButton } from './PropertiesPanel';
import { contentSized, plainTextarea } from './contentSized';
import { stopCanvasEvents } from './stopCanvasEvents';

export interface DataNodeData extends Record<string, unknown> {
Expand All @@ -35,27 +37,29 @@ function formatValue(value: DecoratorValue): string {
const oneLine = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } as const;

function StepMenu({ index, count, onMove, onRemove }: { count: number; index: number; onMove: (to: number) => void; onRemove: () => void }) {
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const menu = useMenuAnchor();
const close = (action: () => void) => () => {
action();
setAnchor(null);
menu.close();
};
return (
<>
<IconButton className="nodrag nopan" size="small" title="Move or remove this step" onClick={event => setAnchor(event.currentTarget)} sx={{ p: 0 }}>
<IconButton className="nodrag nopan" size="small" title="Move or remove this step" onClick={event => menu.open(event.currentTarget)} sx={{ p: 0 }}>
<MoreHorizIcon sx={{ fontSize: 16 }} />
</IconButton>
<Menu {...stopCanvasEvents} anchorEl={anchor} open={Boolean(anchor)} onClose={() => setAnchor(null)}>
<MenuItem disabled={index === 0} onClick={close(() => onMove(index - 1))}>
<ListItemText primary="Move up" />
</MenuItem>
<MenuItem disabled={index === count - 1} onClick={close(() => onMove(index + 1))}>
<ListItemText primary="Move down" />
</MenuItem>
<MenuItem onClick={close(onRemove)}>
<ListItemText primary="Remove step" />
</MenuItem>
</Menu>
{menu.mounted && (
<Menu {...stopCanvasEvents} anchorEl={menu.anchor} open={Boolean(menu.anchor)} onClose={menu.close}>
<MenuItem disabled={index === 0} onClick={close(() => onMove(index - 1))}>
<ListItemText primary="Move up" />
</MenuItem>
<MenuItem disabled={index === count - 1} onClick={close(() => onMove(index + 1))}>
<ListItemText primary="Move down" />
</MenuItem>
<MenuItem onClick={close(onRemove)}>
<ListItemText primary="Remove step" />
</MenuItem>
</Menu>
)}
</>
);
}
Expand Down Expand Up @@ -186,12 +190,11 @@ export function DataNode({ data, selected }: NodeProps<DataFlowNode>) {
className="nodrag nopan"
size="small"
multiline
maxRows={2}
value={chain.sampleInput}
onChange={event => onSampleChange(event.target.value)}
placeholder="Sample input, to preview the steps…"
sx={{ mb: 0.5, ml: 2.75, width: 'calc(100% - 22px)', '& .MuiInputBase-root': { py: 0.5, px: 0.75 } }}
slotProps={{ htmlInput: { style: { fontSize: 10, fontFamily: 'monospace', lineHeight: 1.3 } } }}
slotProps={{ input: plainTextarea, htmlInput: { style: { ...contentSized(1, 2), fontSize: 10, fontFamily: 'monospace', lineHeight: 1.3 } } }}
/>
)}
{chain.steps.map((step, index) => {
Expand Down
Loading
Loading