-
Notifications
You must be signed in to change notification settings - Fork 21
Feature/#812 implement vscode extension and mcp #831
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a41b1a0
22c7cc9
4656961
45f329f
d6d3f5c
17c754d
274eed8
927a782
dc43f2b
d16ec66
2282316
471bd68
189d822
bb0a432
84c1797
683b438
262935c
9bcee08
0725a11
6af786a
4325fa2
31ef351
07ebfb2
f6cddb6
b5ded2c
9c4cdd9
7820721
c3dd8b4
f1d53e1
2c098d2
1cc4bc7
4f6612e
61357bd
df2e92c
87c2fb2
7d14768
c0575fe
4ca0046
c7c082f
a726ee1
57dc5c1
2a44175
73b29c9
d31a41b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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, | ||
| }; | ||
| } |
| 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'; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| 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>>(); | ||
|
|
||
| const resolveParentOrigin = (): string => { | ||
| if (!document.referrer) return '*'; | ||
| try { | ||
| return new URL(document.referrer).origin; | ||
| } catch { | ||
| return '*'; | ||
| } | ||
| }; | ||
|
|
||
| const parentOrigin = resolveParentOrigin(); | ||
|
|
||
| export const sendToExtension = (msg: AppMessage): void => { | ||
| if (!isVSCodeEnv()) return; | ||
| window.parent.postMessage(msg, parentOrigin); | ||
| }; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Seguridad:
|
||
|
|
||
| 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); | ||
| }); | ||
| } | ||
| 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]); | ||
| } |
| 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]); | ||
| } |
| 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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { useHeadlessRenderComplete } from './use-headless-render-complete.hook'; | ||
| import { useVSCodeAutoSave } from './use-vscode-auto-save.hook'; | ||
| import { useVSCodeFileLoad } from './use-vscode-file-load.hook'; | ||
| import { useVSCodeTheme } from './use-vscode-theme.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); | ||
| useVSCodeTheme(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { isVSCodeEnv } from '#common/utils/env.utils'; | ||
| import { onMessage } from '#common/utils/vscode-bridge.utils'; | ||
| import { | ||
| HOST_MESSAGE_TYPE, | ||
| type ThemePayload, | ||
| } from '@lemoncode/quickmock-bridge-protocol'; | ||
| import { useEffect } from 'react'; | ||
|
|
||
| const CSS_VAR_MAP: Record<keyof ThemePayload, readonly string[]> = { | ||
| background: ['--primary-100', '--primary-200'], | ||
| backgroundSecondary: ['--pure-white'], | ||
| foreground: ['--primary-700'], | ||
| }; | ||
|
|
||
| const applyTheme = (theme: ThemePayload): void => { | ||
| const root = document.documentElement; | ||
| for (const [key, cssVars] of Object.entries(CSS_VAR_MAP)) { | ||
| const value = theme[key as keyof ThemePayload]; | ||
| if (!value) continue; | ||
| for (const cssVar of cssVars) { | ||
| root.style.setProperty(cssVar, value); | ||
| } | ||
| } | ||
| if (theme.background) | ||
| document.body.style.setProperty('background-color', theme.background); | ||
| if (theme.foreground) | ||
| document.body.style.setProperty('color', theme.foreground); | ||
| }; | ||
|
|
||
| export const useVSCodeTheme = (): void => { | ||
| useEffect(() => { | ||
| if (!isVSCodeEnv()) return; | ||
| return onMessage(HOST_MESSAGE_TYPE.THEME, applyTheme); | ||
| }, []); | ||
| }; |
| 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)); | ||
| } |
There was a problem hiding this comment.
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.appUrlto 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).