Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a41b1a0
feat(mcp): implement wireframe tools and services
Ivanruii Apr 17, 2026
22c7cc9
feat(vscode-extension): initial migration and integration
Ivanruii Apr 17, 2026
4656961
feat(vscode-extension): implement script to copy MCP build output
Ivanruii Apr 17, 2026
45f329f
feat(vscode-extension): refactor app URL handling to use environment …
Ivanruii Apr 17, 2026
d6d3f5c
feat(mcp): update type imports and enhance data URL parsing logic
Ivanruii Apr 17, 2026
17c754d
feat: implement quickmock bridge protocol and integrate VSCode extens…
Ivanruii Apr 17, 2026
274eed8
feat(vscode-extension): update VSCode URL and adjust type dependencies
Ivanruii Apr 17, 2026
927a782
feat(vscode-extension): add environment variable for QM_APP_URL in la…
Ivanruii Apr 17, 2026
dc43f2b
feat: bundle Chromium, share app URL via ~/.quickmock, and reorganize…
Ivanruii Apr 20, 2026
d16ec66
style: standardize code formatting
Ivanruii Apr 20, 2026
2282316
chore(changeset): initial 0.1.0 release
Ivanruii Apr 20, 2026
471bd68
feat(vscode-extension): include QM_APP_ORIGIN in bridge server for if…
Ivanruii Apr 20, 2026
189d822
feat(mcp): include QM_APP_ORIGIN in postMessage for iframe file loading
Ivanruii Apr 20, 2026
bb0a432
refactor(headless.renderer): remove unused browser launch arguments
Ivanruii Apr 20, 2026
84c1797
feat: implement quickmock registry protocol and integrate with mcp an…
Ivanruii Apr 20, 2026
683b438
feat(vscode-extension): add sandbox attribute to iframe for allow png…
Ivanruii Apr 20, 2026
262935c
fix(mcp): correct log level from error to info in logInfo function
Ivanruii Apr 20, 2026
9bcee08
fix(mcp): simplify server entry retrieval in cleanupStaleMcpRegistration
Ivanruii Apr 21, 2026
0725a11
feat(bridge-protocol, registry-protocol): add message types and const…
nasdan Apr 21, 2026
6af786a
feat(vscode-extension): update package configuration for module suppo…
nasdan Apr 21, 2026
4325fa2
feat(mcp, vscode-extension): update dependencies and remove copy scri…
nasdan Apr 21, 2026
31ef351
feat(config): update storage path and authentication settings for loc…
Ivanruii Apr 22, 2026
07ebfb2
feat(mcp, vscode-extension): deliver MCP via npx instead of bundling …
Ivanruii Apr 22, 2026
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
26 changes: 26 additions & 0 deletions .changeset/quick-meals-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'quickmock': minor
'@lemoncode/quickmock-mcp': minor
---

First public release of the QuickMock VS Code extension and its MCP server.

**`quickmock` (VS Code extension)**

- Custom editor for `.qm` files backed by the QuickMock web app, served inside a webview.

- `quickmock.appUrl` setting (default `https://quickmock.net/editor.html`) to point the editor and the MCP renderer at any QuickMock instance. Changes refresh open editors and respawn the MCP server.

- Automatic MCP server registration for VS Code / GitHub Copilot, Claude Code, Cursor, Windsurf and Claude Desktop, plus a dynamic `McpServerDefinitionProvider`. Existing entries are refreshed on activation so users always end up pointing at the right MCP invocation.

- The MCP server is no longer bundled inside the `.vsix`. In production the extension spawns it on demand via `npx -y @lemoncode/quickmock-mcp`, so users always run the latest published MCP without waiting for an extension release. In development it resolves the local workspace build.

**`@lemoncode/quickmock-mcp` (MCP server)**

- MCP tools to explore and render wireframes: `list_wireframes`, `get_wireframe_json`, `get_wireframe_pages`, `get_wireframe_assets` and `capture_wireframe`.

- Headless screenshot pipeline via `puppeteer-core` against the QuickMock app, using a postMessage bridge.

- On-demand Chromium download via `@puppeteer/browsers`, cached under `~/.quickmock/browsers`, so headless rendering works without relying on the user's local browser install.

- Reads the target app URL from `~/.quickmock/app-url` (written by the extension) with a production fallback, so the MCP works out of the box regardless of how it is spawned.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
"editor.codeActionsOnSave": {
"source.organizeImports": "always",
"source.removeUnusedImports": "always"
}
},
"quickmock.appUrl": "http://localhost:5173/editor.html"
Comment on lines +8 to +9
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workspace-level VS Code setting forces quickmock.appUrl to a localhost dev server for everyone who opens the repo. That’s likely to break the extension/editor unless the dev server is running. Consider removing this from source control or moving it to a documented example (e.g. .vscode/settings.example.json).

Suggested change
},
"quickmock.appUrl": "http://localhost:5173/editor.html"
}

Copilot uses AI. Check for mistakes.
}
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.7.10",
"@lemoncode/quickmock-bridge-protocol": "*",
"@fontsource-variable/montserrat": "5.0.20",
"@fontsource/balsamiq-sans": "5.0.21",
"@uiw/react-color-chrome": "2.10.1",
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { ModalDialogComponent } from './common/components/modal-dialog';
import { useVSCodeSync } from '#core/vscode/use-vscode-sync.hook';
import { MainScene } from './scenes/main.scene';

function App() {
useVSCodeSync();

return (
<>
<ModalDialogComponent />
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/common/utils/compute-content-bbox.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { ContentBbox } from '@lemoncode/quickmock-bridge-protocol';
import type { useCanvasContext } from '#core/providers';

const CONTENT_PADDING = 16;

export function computeContentBbox(
shapes: ReturnType<typeof useCanvasContext>['shapes'],
stageRef: ReturnType<typeof useCanvasContext>['stageRef']
): ContentBbox | undefined {
const stage = stageRef.current;
if (!stage || shapes.length === 0) return undefined;

const scale = stage.scaleX();
const stageX = stage.x();
const stageY = stage.y();
const container = stage.container().getBoundingClientRect();

const minX = Math.min(...shapes.map(s => s.x));
const minY = Math.min(...shapes.map(s => s.y));
const maxX = Math.max(...shapes.map(s => s.x + s.width));
const maxY = Math.max(...shapes.map(s => s.y + s.height));

return {
x: Math.max(0, container.left + stageX + minX * scale - CONTENT_PADDING),
y: Math.max(0, container.top + stageY + minY * scale - CONTENT_PADDING),
width: (maxX - minX) * scale + CONTENT_PADDING * 2,
height: (maxY - minY) * scale + CONTENT_PADDING * 2,
};
}
7 changes: 7 additions & 0 deletions apps/web/src/common/utils/env.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const isVSCodeEnv = (): boolean => {
return new URLSearchParams(window.location.search).get('env') === 'vscode';
};

export const isHeadlessEnv = (): boolean => {
return new URLSearchParams(window.location.search).get('headless') === '1';
};
52 changes: 52 additions & 0 deletions apps/web/src/common/utils/vscode-bridge.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type {
AppMessage,
HostMessage,
PayloadOf,
} from '@lemoncode/quickmock-bridge-protocol';
import { isVSCodeEnv } from './env.utils';

type HandlerFor<T extends HostMessage['type']> = (
payload: PayloadOf<HostMessage, T>
) => void;

type AnyHandler = (payload: unknown) => void;

const handlers = new Map<string, Set<AnyHandler>>();

export const sendToExtension = (msg: AppMessage): void => {
if (!isVSCodeEnv()) return;
window.parent.postMessage(msg, '*');
};

export const onMessage = <T extends HostMessage['type']>(
type: T,
handler: HandlerFor<T>
): (() => void) => {
if (!isVSCodeEnv()) return () => {};

const existing = handlers.get(type) ?? new Set<AnyHandler>();
existing.add(handler as AnyHandler);
handlers.set(type, existing);

return () => {
const set = handlers.get(type);
if (!set) return;
set.delete(handler as AnyHandler);
if (set.size === 0) handlers.delete(type);
};
};

if (isVSCodeEnv()) {
window.addEventListener('message', (event: MessageEvent) => {
if (event.source !== window.parent) return;

const msg = event.data as Partial<HostMessage> | undefined;
if (!msg?.type) return;

const set = handlers.get(msg.type);
if (!set) return;

const payload = (msg as { payload?: unknown }).payload;
for (const handler of set) handler(payload);
});
}
34 changes: 34 additions & 0 deletions apps/web/src/core/vscode/use-headless-render-complete.hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { computeContentBbox } from '#common/utils/compute-content-bbox.utils.ts';
import { isHeadlessEnv } from '#common/utils/env.utils.ts';
import { sendToExtension } from '#common/utils/vscode-bridge.utils.ts';
import { useCanvasContext } from '#core/providers';
import { APP_MESSAGE_TYPE } from '@lemoncode/quickmock-bridge-protocol';
import { useEffect } from 'react';

export function useHeadlessRenderComplete(hasReceivedFileRef: {
current: boolean;
}): void {
const { howManyLoadedDocuments, shapes, stageRef } = useCanvasContext();

useEffect(() => {
if (!isHeadlessEnv() || !hasReceivedFileRef.current) return;

let innerRafId = 0;
// Double rAF: the first frame runs after React commits; the second waits
// for Konva to paint the updated canvas, so Puppeteer's screenshot reflects it.
// There was a previous issue when the canvas was blank because the screenshot ran before Konva painted.
const outerRafId = requestAnimationFrame(() => {
innerRafId = requestAnimationFrame(() => {
sendToExtension({
type: APP_MESSAGE_TYPE.RENDER_COMPLETE,
payload: computeContentBbox(shapes, stageRef),
});
});
});

return () => {
cancelAnimationFrame(outerRafId);
cancelAnimationFrame(innerRafId);
};
}, [howManyLoadedDocuments]);
}
49 changes: 49 additions & 0 deletions apps/web/src/core/vscode/use-vscode-auto-save.hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { isHeadlessEnv, isVSCodeEnv } from '#common/utils/env.utils.ts';
import { sendToExtension } from '#common/utils/vscode-bridge.utils.ts';
import { useCanvasContext } from '#core/providers';
import { APP_MESSAGE_TYPE } from '@lemoncode/quickmock-bridge-protocol';
import { useEffect, useRef } from 'react';
import { serializeDocument } from './vscode-sync.utils';

const AUTO_SAVE_DEBOUNCE_MS = 500;

export function useVSCodeAutoSave(hasReceivedFileRef: {
current: boolean;
}): void {
const { fullDocument, howManyLoadedDocuments } = useCanvasContext();

const prevLoadCountRef = useRef(howManyLoadedDocuments);
const lastSavedContentRef = useRef('');
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
if (!isVSCodeEnv() || isHeadlessEnv() || !hasReceivedFileRef.current)
return;

if (prevLoadCountRef.current !== howManyLoadedDocuments) {
prevLoadCountRef.current = howManyLoadedDocuments;
lastSavedContentRef.current = serializeDocument(fullDocument);
return;
}

const content = serializeDocument(fullDocument);

if (content === lastSavedContentRef.current) return;

debounceTimerRef.current = setTimeout(() => {
sendToExtension({
type: APP_MESSAGE_TYPE.SAVE,
payload: { content },
});
lastSavedContentRef.current = content;
debounceTimerRef.current = null;
}, AUTO_SAVE_DEBOUNCE_MS);

return () => {
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
};
}, [fullDocument, howManyLoadedDocuments]);
}
52 changes: 52 additions & 0 deletions apps/web/src/core/vscode/use-vscode-file-load.hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { isHeadlessEnv, isVSCodeEnv } from '#common/utils/env.utils.ts';
import {
onMessage,
sendToExtension,
} from '#common/utils/vscode-bridge.utils.ts';
import { QuickMockFileContract } from '#core/local-disk/local-disk.model';
import { useCanvasContext } from '#core/providers';
import {
APP_MESSAGE_TYPE,
HOST_MESSAGE_TYPE,
type LoadFilePayload,
} from '@lemoncode/quickmock-bridge-protocol';
import { useEffect, useRef } from 'react';
import { deserializeDocument } from './vscode-sync.utils';

export function useVSCodeFileLoad(): { current: boolean } {
const { loadDocument, setFileName } = useCanvasContext();

const loadDocumentRef = useRef(loadDocument);
const setFileNameRef = useRef(setFileName);
useEffect(() => {
loadDocumentRef.current = loadDocument;
setFileNameRef.current = setFileName;
});

const hasReceivedFileRef = useRef(false);

useEffect(() => {
if (!isVSCodeEnv()) return;

const unsubscribe = onMessage(
HOST_MESSAGE_TYPE.LOAD_FILE,
(payload: LoadFilePayload) => {
hasReceivedFileRef.current = true;
setFileNameRef.current(payload.fileName);
loadDocumentRef.current(
deserializeDocument(payload.data as QuickMockFileContract)
);
}
);

sendToExtension({
type: isHeadlessEnv()
? APP_MESSAGE_TYPE.READY
: APP_MESSAGE_TYPE.WEBVIEW_READY,
});

return unsubscribe;
}, []);

return hasReceivedFileRef;
}
13 changes: 13 additions & 0 deletions apps/web/src/core/vscode/use-vscode-sync.hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useHeadlessRenderComplete } from './use-headless-render-complete.hook';
import { useVSCodeAutoSave } from './use-vscode-auto-save.hook';
import { useVSCodeFileLoad } from './use-vscode-file-load.hook';

/**
* Wires the full VS Code webview bridge. Each inner hook no-ops when not
* running inside a webview, so this can be called unconditionally.
*/
export function useVSCodeSync(): void {
const hasReceivedFileRef = useVSCodeFileLoad();
useVSCodeAutoSave(hasReceivedFileRef);
useHeadlessRenderComplete(hasReceivedFileRef);
}
17 changes: 17 additions & 0 deletions apps/web/src/core/vscode/vscode-sync.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { QuickMockFileContract } from '#core/local-disk/local-disk.model';
import {
mapFromQuickMockFileDocumentToApplicationDocument,
mapFromQuickMockFileDocumentToApplicationDocumentV0_1,
mapFromShapesArrayToQuickMockFileDocument,
} from '#core/local-disk/shapes-to-document.mapper';
import { DocumentModel } from '#core/providers/canvas/canvas.model';

export function deserializeDocument(data: QuickMockFileContract) {
return data.version === '0.1'
? mapFromQuickMockFileDocumentToApplicationDocumentV0_1(data)
: mapFromQuickMockFileDocumentToApplicationDocument(data);
}

export function serializeDocument(document: DocumentModel): string {
return JSON.stringify(mapFromShapesArrayToQuickMockFileDocument(document));
}
21 changes: 15 additions & 6 deletions apps/web/src/scenes/main.scene.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,36 @@
import { MainLayout } from '#layout/main.layout';
import classes from './main.module.css';

import { isHeadlessEnv } from '#common/utils/env.utils.ts';
import { useInteractionModeContext } from '#core/providers';
import {
BasicShapesGalleryPod,
CanvasPod,
ToolbarPod,
ContainerGalleryPod,
ComponentGalleryPod,
BasicShapesGalleryPod,
ContainerGalleryPod,
LowWireframeGalleryPod,
RichComponentsGalleryPod,
TextComponetGalleryPod,
LowWireframeGalleryPod,
ToolbarPod,
} from '#pods';
import { PropertiesPod } from '#pods/properties';
import { FooterPod } from '#pods/footer/footer.pod';
import { PropertiesPod } from '#pods/properties';
import { ThumbPagesPod } from '#pods/thumb-pages';
import { useAccordionSectionVisibility } from './accordion-section-visibility.hook';
import { useInteractionModeContext } from '#core/providers';

export const MainScene = () => {
const { isThumbPagesPodOpen, thumbPagesPodRef } =
useAccordionSectionVisibility();
const { interactionMode } = useInteractionModeContext();

if (isHeadlessEnv()) {
return (
<div style={{ width: '100vw', height: '100vh', overflow: 'hidden' }}>
<CanvasPod />
</div>
);
}

return (
<MainLayout>
{interactionMode === 'view' && (
Expand Down
15 changes: 10 additions & 5 deletions local-npm-registry/config/config.yaml
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
storage: ./storage
storage: /verdaccio/storage

auth:
htpasswd:
file: /verdaccio/storage/htpasswd
max_users: 1000

uplinks:
npmjs:
url: https://registry.npmjs.org/

packages:
'@lemoncode/*':
access: $anonymous
publish: $anonymous
access: $all
publish: $authenticated
'**':
access: $anonymous
publish: $anonymous
access: $all
publish: $authenticated
proxy: npmjs
Loading
Loading