diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..a9a98328 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Mongo Modeler Extension", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/packages/vscode-extension" + ], + "outFiles": ["${workspaceFolder}/packages/vscode-extension/dist/**/*.mjs"] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..fa2d2ef2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "mongo-modeler.appUrl": "http://localhost:5173/editor.html" +} diff --git a/apps/web/src/core/vscode/env.helpers.ts b/apps/web/src/core/vscode/env.helpers.ts new file mode 100644 index 00000000..c1cd5712 --- /dev/null +++ b/apps/web/src/core/vscode/env.helpers.ts @@ -0,0 +1,2 @@ +export const isVSCodeEnv = (): boolean => + new URLSearchParams(window.location.search).get('env') === 'vscode'; diff --git a/apps/web/src/core/vscode/index.ts b/apps/web/src/core/vscode/index.ts new file mode 100644 index 00000000..e270eacb --- /dev/null +++ b/apps/web/src/core/vscode/index.ts @@ -0,0 +1 @@ +export * from './use-vscode-sync.hook'; diff --git a/apps/web/src/core/vscode/use-vscode-auto-save.hook.ts b/apps/web/src/core/vscode/use-vscode-auto-save.hook.ts new file mode 100644 index 00000000..229a20e9 --- /dev/null +++ b/apps/web/src/core/vscode/use-vscode-auto-save.hook.ts @@ -0,0 +1,46 @@ +import { APP_MESSAGE_TYPE } from '@lemoncode/mongo-modeler-bridge-protocol'; +import { useEffect, useRef, type MutableRefObject } from 'react'; +import { useCanvasSchemaContext } from '@/core/providers'; +import { isVSCodeEnv } from './env.helpers'; +import { sendToExtension } from './vscode-bridge.helpers'; +import { serializeSchema } from './vscode-sync.helpers'; + +const AUTO_SAVE_DEBOUNCE_MS = 500; + +export const useVSCodeAutoSave = ( + hasReceivedFileRef: MutableRefObject +): void => { + const { canvasSchema } = useCanvasSchemaContext(); + + const lastSavedContentRef = useRef(null); + const debounceTimerRef = useRef | null>(null); + + useEffect(() => { + if (!isVSCodeEnv() || !hasReceivedFileRef.current) return; + + const content = serializeSchema(canvasSchema); + + if (lastSavedContentRef.current === null) { + lastSavedContentRef.current = content; + return; + } + + 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; + } + }; + }, [canvasSchema]); +}; diff --git a/apps/web/src/core/vscode/use-vscode-file-load.hook.ts b/apps/web/src/core/vscode/use-vscode-file-load.hook.ts new file mode 100644 index 00000000..82cdf1f8 --- /dev/null +++ b/apps/web/src/core/vscode/use-vscode-file-load.hook.ts @@ -0,0 +1,50 @@ +import { + useCanvasSchemaContext, + useCanvasViewSettingsContext, +} from '@/core/providers'; +import { + APP_MESSAGE_TYPE, + HOST_MESSAGE_TYPE, + type LoadFilePayload, +} from '@lemoncode/mongo-modeler-bridge-protocol'; +import { useEffect, useRef, type MutableRefObject } from 'react'; +import { isVSCodeEnv } from './env.helpers'; +import { onMessage, sendToExtension } from './vscode-bridge.helpers'; +import { deserializeSchema } from './vscode-sync.helpers'; + +export const useVSCodeFileLoad = (): MutableRefObject => { + const { loadSchema } = useCanvasSchemaContext(); + const { setFilename, setLoadSample } = useCanvasViewSettingsContext(); + + const loadSchemaRef = useRef(loadSchema); + const setFilenameRef = useRef(setFilename); + const setLoadSampleRef = useRef(setLoadSample); + + useEffect(() => { + loadSchemaRef.current = loadSchema; + setFilenameRef.current = setFilename; + setLoadSampleRef.current = setLoadSample; + }); + + 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); + setLoadSampleRef.current(false); + loadSchemaRef.current(deserializeSchema(payload.data)); + } + ); + + sendToExtension({ type: APP_MESSAGE_TYPE.WEBVIEW_READY }); + + return unsubscribe; + }, []); + + return hasReceivedFileRef; +}; diff --git a/apps/web/src/core/vscode/use-vscode-sync.hook.ts b/apps/web/src/core/vscode/use-vscode-sync.hook.ts new file mode 100644 index 00000000..ab933994 --- /dev/null +++ b/apps/web/src/core/vscode/use-vscode-sync.hook.ts @@ -0,0 +1,13 @@ +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 VS Code webview bridge. The inner hooks no-op when not running + * inside a VS Code webview, so this can be called unconditionally. + */ +export const useVSCodeSync = (): void => { + const hasReceivedFileRef = useVSCodeFileLoad(); + useVSCodeAutoSave(hasReceivedFileRef); + useVSCodeTheme(); +}; diff --git a/apps/web/src/core/vscode/use-vscode-theme.hook.ts b/apps/web/src/core/vscode/use-vscode-theme.hook.ts new file mode 100644 index 00000000..551ca5ae --- /dev/null +++ b/apps/web/src/core/vscode/use-vscode-theme.hook.ts @@ -0,0 +1,39 @@ +import { + HOST_MESSAGE_TYPE, + type ThemePayload, +} from '@lemoncode/mongo-modeler-bridge-protocol'; +import { useEffect } from 'react'; +import { isVSCodeEnv } from './env.helpers'; +import { onMessage } from './vscode-bridge.helpers'; + +const CSS_VAR_MAP: Record = { + background: ['--bg-canvas', '--background-800', '--background-900'], + backgroundSecondary: [ + '--bg-toolbar', + '--bg-table', + '--bg-input', + '--background-700', + '--background-400', + ], + foreground: ['--text-color'], +}; + +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.backgroundColor = theme.background; + if (theme.foreground) document.body.style.color = theme.foreground; +}; + +export const useVSCodeTheme = (): void => { + useEffect(() => { + if (!isVSCodeEnv()) return; + return onMessage(HOST_MESSAGE_TYPE.THEME, applyTheme); + }, []); +}; diff --git a/apps/web/src/core/vscode/vscode-bridge.helpers.ts b/apps/web/src/core/vscode/vscode-bridge.helpers.ts new file mode 100644 index 00000000..9253eb86 --- /dev/null +++ b/apps/web/src/core/vscode/vscode-bridge.helpers.ts @@ -0,0 +1,52 @@ +import type { + AppMessage, + HostMessage, + PayloadOf, +} from '@lemoncode/mongo-modeler-bridge-protocol'; +import { isVSCodeEnv } from './env.helpers'; + +type HandlerFor = ( + payload: PayloadOf +) => void; + +type AnyHandler = (payload: unknown) => void; + +const handlers = new Map>(); + +export const sendToExtension = (msg: AppMessage): void => { + if (!isVSCodeEnv()) return; + window.parent.postMessage(msg, '*'); +}; + +export const onMessage = ( + type: T, + handler: HandlerFor +): (() => void) => { + if (!isVSCodeEnv()) return () => {}; + + const existing = handlers.get(type) ?? new Set(); + 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 (typeof window !== 'undefined' && isVSCodeEnv()) { + window.addEventListener('message', (event: MessageEvent) => { + if (event.source !== window.parent) return; + + const msg = event.data as Partial | 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); + }); +} diff --git a/apps/web/src/core/vscode/vscode-sync.helpers.ts b/apps/web/src/core/vscode/vscode-sync.helpers.ts new file mode 100644 index 00000000..bcef9668 --- /dev/null +++ b/apps/web/src/core/vscode/vscode-sync.helpers.ts @@ -0,0 +1,8 @@ +import { type DatabaseSchemaVm } from '@/core/providers'; +import { mapSchemaToLatestVersion } from '@/core/providers/canvas-schema/canvas-schema.mapper'; + +export const deserializeSchema = (data: unknown): DatabaseSchemaVm => + mapSchemaToLatestVersion(data); + +export const serializeSchema = (schema: DatabaseSchemaVm): string => + JSON.stringify(schema); diff --git a/apps/web/src/pods/toolbar/toolbar.pod.tsx b/apps/web/src/pods/toolbar/toolbar.pod.tsx index 98d21997..aa114d2e 100644 --- a/apps/web/src/pods/toolbar/toolbar.pod.tsx +++ b/apps/web/src/pods/toolbar/toolbar.pod.tsx @@ -1,30 +1,33 @@ +import { isVSCodeEnv } from '@/core/vscode/env.helpers'; import React from 'react'; import { - // CanvasSettingButton, - ZoomInButton, - ZoomOutButton, - ThemeToggleButton, + AboutButton, + CanvasSettingButton, + CopyButton, + DeleteButton, ExportButton, + ImportButton, NewButton, OpenButton, + PasteButton, + RedoButton, SaveButton, + ThemeToggleButton, UndoButton, - RedoButton, - DeleteButton, - AboutButton, - CanvasSettingButton, - CopyButton, - PasteButton, - ImportButton, + // CanvasSettingButton, + ZoomInButton, + ZoomOutButton, } from './components'; import classes from './toolbar.pod.module.css'; export const ToolbarPod: React.FC = () => { + const isVSCode = isVSCodeEnv(); + return (
- - + {!isVSCode && } + {!isVSCode && } @@ -36,7 +39,9 @@ export const ToolbarPod: React.FC = () => { - + {!isVSCode && ( + + )}
); }; diff --git a/apps/web/src/scenes/main.scene.tsx b/apps/web/src/scenes/main.scene.tsx index 34c4c066..b935ea7a 100644 --- a/apps/web/src/scenes/main.scene.tsx +++ b/apps/web/src/scenes/main.scene.tsx @@ -1,14 +1,18 @@ +import { ModalDialog } from '@/common/components'; +import { useDeviceContext, useModalDialogContext } from '@/core/providers'; +import { useVSCodeSync } from '@/core/vscode'; import { CanvasPod } from '@/pods/canvas/canvas.pod'; +import { FloatingBarPod } from '@/pods/floating-bar'; +import { FooterPod } from '@/pods/footer'; import { ToolbarPod } from '@/pods/toolbar/toolbar.pod'; -import { useDeviceContext, useModalDialogContext } from '@/core/providers'; -import { ModalDialog } from '@/common/components'; import classes from './main.scene.module.css'; -import { FooterPod } from '@/pods/footer'; -import { FloatingBarPod } from '@/pods/floating-bar'; export const MainScene: React.FC = () => { const { modalDialog } = useModalDialogContext(); const { isTabletOrMobileDevice } = useDeviceContext(); + + useVSCodeSync(); + return ( <>
diff --git a/package-lock.json b/package-lock.json index a61e65ee..e6b464fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1415,7 +1415,7 @@ } }, "node_modules/@lemoncode/mongo-modeler-bridge-protocol": { - "resolved": "packages/bridge-protocol", + "resolved": "packages/vscode-bridge-protocol", "link": true }, "node_modules/@lemoncode/mongo-modeler-web": { @@ -10855,6 +10855,14 @@ } }, "packages/bridge-protocol": { + "name": "@lemoncode/mongo-modeler-bridge-protocol", + "version": "0.0.0", + "extraneous": true, + "devDependencies": { + "@lemoncode/typescript-config": "*" + } + }, + "packages/vscode-bridge-protocol": { "name": "@lemoncode/mongo-modeler-bridge-protocol", "version": "0.0.0", "devDependencies": { diff --git a/packages/bridge-protocol/src/index.ts b/packages/bridge-protocol/src/index.ts deleted file mode 100644 index cb0ff5c3..00000000 --- a/packages/bridge-protocol/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/bridge-protocol/package.json b/packages/vscode-bridge-protocol/package.json similarity index 100% rename from packages/bridge-protocol/package.json rename to packages/vscode-bridge-protocol/package.json diff --git a/packages/vscode-bridge-protocol/src/constant.ts b/packages/vscode-bridge-protocol/src/constant.ts new file mode 100644 index 00000000..ce5ae477 --- /dev/null +++ b/packages/vscode-bridge-protocol/src/constant.ts @@ -0,0 +1,11 @@ +export const HOST_MESSAGE_TYPE = { + SAVED: 'mm:saved', + LOAD_FILE: 'mm:load-file', + THEME: 'mm:theme', +} as const; + +export const APP_MESSAGE_TYPE = { + READY: 'mm:ready', + SAVE: 'mm:save', + WEBVIEW_READY: 'mm:webview-ready', +} as const; diff --git a/packages/vscode-bridge-protocol/src/index.ts b/packages/vscode-bridge-protocol/src/index.ts new file mode 100644 index 00000000..61dc567b --- /dev/null +++ b/packages/vscode-bridge-protocol/src/index.ts @@ -0,0 +1,2 @@ +export * from './constant'; +export * from './model'; diff --git a/packages/vscode-bridge-protocol/src/model.ts b/packages/vscode-bridge-protocol/src/model.ts new file mode 100644 index 00000000..eb66c1c2 --- /dev/null +++ b/packages/vscode-bridge-protocol/src/model.ts @@ -0,0 +1,25 @@ +import type { APP_MESSAGE_TYPE, HOST_MESSAGE_TYPE } from './constant'; + +export interface LoadFilePayload { + data: unknown; + fileName: string; +} + +export interface ThemePayload { + background: string; + backgroundSecondary: string; + foreground: string; +} + +export type HostMessage = + | { type: typeof HOST_MESSAGE_TYPE.SAVED } + | { type: typeof HOST_MESSAGE_TYPE.LOAD_FILE; payload: LoadFilePayload } + | { type: typeof HOST_MESSAGE_TYPE.THEME; payload: ThemePayload }; + +export type AppMessage = + | { type: typeof APP_MESSAGE_TYPE.READY } + | { type: typeof APP_MESSAGE_TYPE.WEBVIEW_READY } + | { type: typeof APP_MESSAGE_TYPE.SAVE; payload: { content: string } }; + +export type PayloadOf = + Extract extends { payload: infer P } ? P : undefined; diff --git a/packages/bridge-protocol/tsconfig.json b/packages/vscode-bridge-protocol/tsconfig.json similarity index 100% rename from packages/bridge-protocol/tsconfig.json rename to packages/vscode-bridge-protocol/tsconfig.json diff --git a/packages/vscode-extension/src/commands/index.ts b/packages/vscode-extension/src/commands/index.ts new file mode 100644 index 00000000..29ffc893 --- /dev/null +++ b/packages/vscode-extension/src/commands/index.ts @@ -0,0 +1,2 @@ +export * from './new-diagram'; +export * from './register'; diff --git a/packages/vscode-extension/src/commands/new-diagram.ts b/packages/vscode-extension/src/commands/new-diagram.ts new file mode 100644 index 00000000..b0b98659 --- /dev/null +++ b/packages/vscode-extension/src/commands/new-diagram.ts @@ -0,0 +1,52 @@ +import { logError } from '#core'; +import { writeFile } from '#editor'; +import * as vscode from 'vscode'; + +const VIEW_TYPE = 'mongo-modeler.editor'; +const FILE_EXTENSION = 'mml'; +const DEFAULT_FILENAME = `untitled.${FILE_EXTENSION}`; + +const DEFAULT_DIAGRAM_CONTENT = JSON.stringify( + { + version: '0.1', + tables: [], + relations: [], + notes: [], + selectedElementId: null, + isPristine: true, + }, + null, + 2 +); + +const getDefaultUri = (): vscode.Uri | undefined => { + const folder = vscode.workspace.workspaceFolders?.[0]; + return folder ? vscode.Uri.joinPath(folder.uri, DEFAULT_FILENAME) : undefined; +}; + +const createNewDiagram = async (): Promise => { + const target = await vscode.window.showSaveDialog({ + title: 'New Mongo Modeler diagram', + defaultUri: getDefaultUri(), + filters: { 'Mongo Modeler diagram': [FILE_EXTENSION] }, + }); + if (!target) return; + + try { + await writeFile(target, DEFAULT_DIAGRAM_CONTENT); + await vscode.commands.executeCommand('vscode.openWith', target, VIEW_TYPE); + } catch (error) { + logError('Failed to create new diagram:', error); + vscode.window.showErrorMessage( + `Failed to create new Mongo Modeler diagram: ${error instanceof Error ? error.message : String(error)}` + ); + } +}; + +export const registerNewDiagramCommand = ( + context: vscode.ExtensionContext +): void => { + context.subscriptions.push( + vscode.commands.registerCommand('mongo-modeler.newDiagram', createNewDiagram) + ); +}; diff --git a/packages/vscode-extension/src/commands/register.ts b/packages/vscode-extension/src/commands/register.ts new file mode 100644 index 00000000..8d40e475 --- /dev/null +++ b/packages/vscode-extension/src/commands/register.ts @@ -0,0 +1,6 @@ +import * as vscode from 'vscode'; +import { registerNewDiagramCommand } from './new-diagram'; + +export const registerCommands = (context: vscode.ExtensionContext): void => { + registerNewDiagramCommand(context); +}; diff --git a/packages/vscode-extension/src/core/config.ts b/packages/vscode-extension/src/core/config.ts new file mode 100644 index 00000000..6796f318 --- /dev/null +++ b/packages/vscode-extension/src/core/config.ts @@ -0,0 +1,29 @@ +import * as vscode from 'vscode'; + +const SECTION = 'mongo-modeler'; +const APP_URL_KEY = 'appUrl'; +const FULL_KEY = `${SECTION}.${APP_URL_KEY}`; +const DEFAULT_APP_URL = 'https://mongomodeler.com/editor.html'; + +const EDITOR_PARAMS = { env: 'vscode' } as const; + +const readRawAppUrl = (): string => { + const value = vscode.workspace + .getConfiguration(SECTION) + .get(APP_URL_KEY); + return value?.trim() || DEFAULT_APP_URL; +}; + +const withParams = (url: string, params: Record): string => { + const parsed = new URL(url); + for (const [k, v] of Object.entries(params)) parsed.searchParams.set(k, v); + return parsed.toString(); +}; + +export const getEditorAppUrl = (): string => + withParams(readRawAppUrl(), EDITOR_PARAMS); + +export const onAppUrlChange = (listener: () => void): vscode.Disposable => + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(FULL_KEY)) listener(); + }); diff --git a/packages/vscode-extension/src/core/index.ts b/packages/vscode-extension/src/core/index.ts new file mode 100644 index 00000000..707452af --- /dev/null +++ b/packages/vscode-extension/src/core/index.ts @@ -0,0 +1,2 @@ +export * from './config'; +export * from './logger'; diff --git a/packages/vscode-extension/src/core/logger.ts b/packages/vscode-extension/src/core/logger.ts new file mode 100644 index 00000000..a2c5e2dc --- /dev/null +++ b/packages/vscode-extension/src/core/logger.ts @@ -0,0 +1,9 @@ +const PREFIX = '[MongoModeler]'; + +export const logInfo = (message: string, ...rest: unknown[]): void => { + console.info(`${PREFIX} ${message}`, ...rest); +}; + +export const logError = (message: string, ...rest: unknown[]): void => { + console.error(`${PREFIX} ${message}`, ...rest); +}; diff --git a/packages/vscode-extension/src/editor/document.ts b/packages/vscode-extension/src/editor/document.ts new file mode 100644 index 00000000..c7b07205 --- /dev/null +++ b/packages/vscode-extension/src/editor/document.ts @@ -0,0 +1,29 @@ +import * as vscode from 'vscode'; + +export type MongoModelerDocument = vscode.CustomDocument & { + readonly uri: vscode.Uri; + content: string; +}; + +export const openDocument = async ( + uri: vscode.Uri, + openContext: vscode.CustomDocumentOpenContext +): Promise => { + const source = openContext.backupId + ? vscode.Uri.parse(openContext.backupId) + : uri; + const content = await readFile(source); + return { uri, content, dispose: () => {} }; +}; + +export const readFile = async (uri: vscode.Uri): Promise => { + const bytes = await vscode.workspace.fs.readFile(uri); + return new TextDecoder().decode(bytes); +}; + +export const writeFile = async ( + uri: vscode.Uri, + content: string +): Promise => { + await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(content)); +}; diff --git a/packages/vscode-extension/src/editor/handlers.ts b/packages/vscode-extension/src/editor/handlers.ts new file mode 100644 index 00000000..bf2c6c87 --- /dev/null +++ b/packages/vscode-extension/src/editor/handlers.ts @@ -0,0 +1,38 @@ +import { basename } from 'node:path'; +import { + APP_MESSAGE_TYPE, + type AppMessage, + HOST_MESSAGE_TYPE, + type HostMessage, +} from '@lemoncode/mongo-modeler-bridge-protocol'; +import { type MongoModelerDocument, writeFile } from './document'; + +type PostMessageFn = (msg: HostMessage) => void; + +export const handleWebviewMessage = async ( + msg: AppMessage, + doc: MongoModelerDocument, + postMessage: PostMessageFn +): Promise => { + switch (msg.type) { + case APP_MESSAGE_TYPE.WEBVIEW_READY: { + let data: unknown; + try { + data = JSON.parse(doc.content); + } catch { + data = doc.content; + } + postMessage({ + type: HOST_MESSAGE_TYPE.LOAD_FILE, + payload: { data, fileName: basename(doc.uri.fsPath) }, + }); + break; + } + + case APP_MESSAGE_TYPE.SAVE: + doc.content = msg.payload.content; + await writeFile(doc.uri, doc.content); + postMessage({ type: HOST_MESSAGE_TYPE.SAVED }); + break; + } +}; diff --git a/packages/vscode-extension/src/editor/index.ts b/packages/vscode-extension/src/editor/index.ts new file mode 100644 index 00000000..3951f00f --- /dev/null +++ b/packages/vscode-extension/src/editor/index.ts @@ -0,0 +1,4 @@ +export * from './document'; +export * from './handlers'; +export * from './panel'; +export * from './provider'; diff --git a/packages/vscode-extension/src/editor/panel.ts b/packages/vscode-extension/src/editor/panel.ts new file mode 100644 index 00000000..f943115f --- /dev/null +++ b/packages/vscode-extension/src/editor/panel.ts @@ -0,0 +1,33 @@ +import * as vscode from 'vscode'; + +const escapeAttr = (value: string): string => + value.replace(/&/g, '&').replace(/"/g, '"'); + +export const getHtml = ( + webview: vscode.Webview, + extensionUri: vscode.Uri, + appUrl: string +): string => { + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, 'dist', 'webview.js') + ); + const appOrigin = new URL(appUrl).origin; + const wsOrigin = appOrigin.replace(/^http/, 'ws'); + + return /* html */ ` + + + + + + + + + + +`; +}; diff --git a/packages/vscode-extension/src/editor/provider.ts b/packages/vscode-extension/src/editor/provider.ts new file mode 100644 index 00000000..917f5293 --- /dev/null +++ b/packages/vscode-extension/src/editor/provider.ts @@ -0,0 +1,142 @@ +import { getEditorAppUrl, onAppUrlChange } from '#core'; +import { + type AppMessage, + HOST_MESSAGE_TYPE, + type HostMessage, +} from '@lemoncode/mongo-modeler-bridge-protocol'; +import { basename } from 'node:path'; +import * as vscode from 'vscode'; +import { + type MongoModelerDocument, + openDocument, + readFile, + writeFile, +} from './document'; +import { handleWebviewMessage } from './handlers'; +import { getHtml } from './panel'; + +export class MongoModelerEditorProvider + implements vscode.CustomEditorProvider { + static register(context: vscode.ExtensionContext): vscode.Disposable { + const provider = new MongoModelerEditorProvider(context.extensionUri); + const editorRegistration = vscode.window.registerCustomEditorProvider( + 'mongo-modeler.editor', + provider, + { + supportsMultipleEditorsPerDocument: false, + webviewOptions: { retainContextWhenHidden: true }, + } + ); + const configListener = onAppUrlChange(() => provider.refreshAllPanels()); + return vscode.Disposable.from(editorRegistration, configListener); + } + + constructor(private readonly extensionUri: vscode.Uri) { } + + private readonly _onDidChangeCustomDocument = new vscode.EventEmitter< + vscode.CustomDocumentContentChangeEvent + >(); + readonly onDidChangeCustomDocument = this._onDidChangeCustomDocument.event; + + private readonly panels = new Map(); + + async openCustomDocument( + uri: vscode.Uri, + openContext: vscode.CustomDocumentOpenContext + ): Promise { + return openDocument(uri, openContext); + } + + async saveCustomDocument( + doc: MongoModelerDocument, + _cancel: vscode.CancellationToken + ): Promise { + await writeFile(doc.uri, doc.content); + } + + async saveCustomDocumentAs( + doc: MongoModelerDocument, + dest: vscode.Uri, + _cancel: vscode.CancellationToken + ): Promise { + await writeFile(dest, doc.content); + } + + async revertCustomDocument( + doc: MongoModelerDocument, + _cancel: vscode.CancellationToken + ): Promise { + doc.content = await readFile(doc.uri); + let data: unknown; + try { + data = JSON.parse(doc.content); + } catch { + data = doc.content; + } + this.broadcast(doc, { + type: HOST_MESSAGE_TYPE.LOAD_FILE, + payload: { data, fileName: basename(doc.uri.fsPath) }, + }); + } + + async backupCustomDocument( + doc: MongoModelerDocument, + context: vscode.CustomDocumentBackupContext, + _cancel: vscode.CancellationToken + ): Promise { + await writeFile(context.destination, doc.content); + return { + id: context.destination.toString(), + delete: () => { + vscode.workspace.fs + .delete(context.destination) + .then(undefined, () => { }); + }, + }; + } + + resolveCustomEditor( + doc: MongoModelerDocument, + panel: vscode.WebviewPanel, + _token: vscode.CancellationToken + ): void { + const key = doc.uri.toString(); + this.panels.set(key, [...(this.panels.get(key) ?? []), panel]); + panel.onDidDispose(() => { + const remaining = (this.panels.get(key) ?? []).filter(p => p !== panel); + this.panels.set(key, remaining); + }); + + panel.webview.options = { + enableScripts: true, + localResourceRoots: [this.extensionUri], + }; + + panel.webview.onDidReceiveMessage(async (msg: AppMessage) => { + await handleWebviewMessage(msg, doc, reply => + panel.webview.postMessage(reply satisfies HostMessage) + ); + }); + + panel.webview.html = getHtml( + panel.webview, + this.extensionUri, + getEditorAppUrl() + ); + } + + private broadcast(doc: MongoModelerDocument, msg: HostMessage): void { + for (const panel of this.panels.get(doc.uri.toString()) ?? []) { + panel.webview.postMessage(msg); + } + } + + refreshAllPanels(): void { + const url = getEditorAppUrl(); + for (const panels of this.panels.values()) { + for (const panel of panels) { + panel.webview.html = getHtml(panel.webview, this.extensionUri, url); + } + } + } +} diff --git a/packages/vscode-extension/src/index.ts b/packages/vscode-extension/src/index.ts index 95a1665d..972b8dde 100644 --- a/packages/vscode-extension/src/index.ts +++ b/packages/vscode-extension/src/index.ts @@ -1,7 +1,10 @@ +import { registerCommands } from '#commands'; +import { MongoModelerEditorProvider } from '#editor'; import * as vscode from 'vscode'; -export const activate = (_context: vscode.ExtensionContext) => { - // Extension activation entrypoint. Implementation will be added later. +export const activate = (context: vscode.ExtensionContext) => { + context.subscriptions.push(MongoModelerEditorProvider.register(context)); + registerCommands(context); }; -export const deactivate = () => { }; +export const deactivate = () => {}; diff --git a/packages/vscode-extension/src/webview/bridge.ts b/packages/vscode-extension/src/webview/bridge.ts new file mode 100644 index 00000000..fe605821 --- /dev/null +++ b/packages/vscode-extension/src/webview/bridge.ts @@ -0,0 +1,30 @@ +import { + type AppMessage, + HOST_MESSAGE_TYPE, + type HostMessage, +} from '@lemoncode/mongo-modeler-bridge-protocol'; + +declare function acquireVsCodeApi(): { postMessage(msg: AppMessage): void }; + +const vscode = acquireVsCodeApi(); + +const FORWARDED_TO_IFRAME: ReadonlySet = new Set([ + HOST_MESSAGE_TYPE.SAVED, + HOST_MESSAGE_TYPE.LOAD_FILE, +]); + +export const setupBridge = ( + iframe: HTMLIFrameElement, + appOrigin: string +): void => { + window.addEventListener('message', (event: MessageEvent) => { + if (event.origin === appOrigin) { + vscode.postMessage(event.data as AppMessage); + } else { + const msg = event.data as HostMessage; + if (FORWARDED_TO_IFRAME.has(msg.type)) { + iframe.contentWindow?.postMessage(msg, appOrigin); + } + } + }); +}; diff --git a/packages/vscode-extension/src/webview/main.ts b/packages/vscode-extension/src/webview/main.ts index 9daaf173..73a2f904 100644 --- a/packages/vscode-extension/src/webview/main.ts +++ b/packages/vscode-extension/src/webview/main.ts @@ -1,3 +1,22 @@ -// Webview bootstrap. Implementation will be added later. -export { }; +import { setupBridge } from './bridge'; +import { setupThemeSync } from './theme'; +const appUrl = document.body.dataset.appUrl; +if (!appUrl) { + throw new Error('[MongoModeler] Missing data-app-url attribute on '); +} + +const appOrigin = new URL(appUrl).origin; + +const iframe = document.createElement('iframe'); +iframe.src = appUrl; +iframe.setAttribute( + 'sandbox', + 'allow-scripts allow-same-origin allow-downloads' +); +iframe.allow = 'clipboard-read; clipboard-write'; +iframe.title = 'Mongo Modeler Application'; +document.body.appendChild(iframe); + +setupBridge(iframe, appOrigin); +setupThemeSync(iframe, appOrigin); diff --git a/packages/vscode-extension/src/webview/theme.ts b/packages/vscode-extension/src/webview/theme.ts new file mode 100644 index 00000000..ce705ac0 --- /dev/null +++ b/packages/vscode-extension/src/webview/theme.ts @@ -0,0 +1,52 @@ +import { + APP_MESSAGE_TYPE, + HOST_MESSAGE_TYPE, + type ThemePayload, +} from '@lemoncode/mongo-modeler-bridge-protocol'; + +const readVar = (style: CSSStyleDeclaration, name: string): string => + style.getPropertyValue(name).trim(); + +export const extractTheme = (): ThemePayload => { + const style = getComputedStyle(document.documentElement); + return { + background: readVar(style, '--vscode-editor-background'), + backgroundSecondary: readVar(style, '--vscode-sideBar-background'), + foreground: readVar(style, '--vscode-editor-foreground'), + }; +}; + +const IFRAME_READY_TYPES: ReadonlySet = new Set([ + APP_MESSAGE_TYPE.WEBVIEW_READY, + APP_MESSAGE_TYPE.READY, +]); + +export const setupThemeSync = ( + iframe: HTMLIFrameElement, + appOrigin: string +): (() => void) => { + const sendTheme = (): void => { + iframe.contentWindow?.postMessage( + { type: HOST_MESSAGE_TYPE.THEME, payload: extractTheme() }, + appOrigin + ); + }; + + const onIframeReady = (event: MessageEvent): void => { + if (event.origin !== appOrigin) return; + const type = (event.data as { type?: string } | undefined)?.type; + if (type && IFRAME_READY_TYPES.has(type)) sendTheme(); + }; + window.addEventListener('message', onIframeReady); + + const observer = new MutationObserver(sendTheme); + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class', 'style'], + }); + + return () => { + window.removeEventListener('message', onIframeReady); + observer.disconnect(); + }; +};