Last active
December 16, 2025 09:11
-
-
Save engalar/9878bffebbaf74f977d9c2e134a7242b to your computer and use it in GitHub Desktop.
Mendix DevTool: Inspector & Opener
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <script src="assets/tailwindcss.js"></script> | |
| </head> | |
| <body class="bg-gray-100 font-sans"> | |
| <div id="app"></div> | |
| <script src="assets/vendor-bundle.umd.js"></script> | |
| <script src="assets/babel.min.js"></script> | |
| <script type="text/babel"> | |
| // =================================================================== | |
| // =================== FRAMEWORK CODE ======================== | |
| // =================================================================== | |
| // This section contains the reusable, application-agnostic core. | |
| // You should not need to modify this section to add new features. | |
| // ------------------------------------------------------------------- | |
| // 等价于 | |
| // import * as React from 'react'; | |
| // import ReactDOM from 'react-dom/client'; | |
| // import * as redi from '@wendellhu/redi'; | |
| // import * as rediReact from '@wendellhu/redi/react-bindings'; | |
| // import * as rxjs from 'rxjs'; | |
| // import * as reactSpring from '@react-spring/web'; | |
| const { React, ReactDOM, redi, rediReact, rxjs, reactSpring } = globalThis.__tmp; | |
| delete globalThis.__tmp; | |
| // keep unused import for redi and rediReact | |
| const { | |
| Inject, | |
| Injector, | |
| LookUp, | |
| Many, | |
| Optional, | |
| Quantity, | |
| RediError, | |
| Self, | |
| SkipSelf, | |
| WithNew, | |
| createIdentifier, | |
| forwardRef, | |
| isAsyncDependencyItem, | |
| isAsyncHook, | |
| isClassDependencyItem, | |
| isCtor, | |
| isDisposable, | |
| isFactoryDependencyItem, | |
| isValueDependencyItem, | |
| setDependencies, | |
| } = redi; | |
| const { | |
| RediConsumer, | |
| RediContext, | |
| RediProvider, | |
| WithDependency, | |
| connectDependencies, | |
| connectInjector, | |
| useDependency, | |
| useInjector, | |
| useObservable, | |
| useUpdateBinder, | |
| } = rediReact; | |
| const { | |
| useState, | |
| useEffect, | |
| useRef, | |
| useReducer, | |
| Fragment, | |
| useCallback, | |
| createContext, | |
| useContext, | |
| } = React; | |
| // 1. FRAMEWORK: CORE COMMUNICATION SERVICES | |
| const IBackendEventService = createIdentifier("IBackendEventService"); | |
| class BackendEventService { | |
| constructor() { | |
| this.subscribers = new Map(); | |
| } | |
| subscribe(eventType, callback) { | |
| if (!this.subscribers.has(eventType)) { | |
| this.subscribers.set(eventType, new Set()); | |
| } | |
| this.subscribers.get(eventType).add(callback); | |
| return () => { | |
| const eventSubscribers = this.subscribers.get(eventType); | |
| if (eventSubscribers) { | |
| eventSubscribers.delete(callback); | |
| } | |
| }; | |
| } | |
| publish(eventType, data) { | |
| const eventSubscribers = this.subscribers.get(eventType); | |
| if (eventSubscribers) { | |
| eventSubscribers.forEach((callback) => callback(data)); | |
| } | |
| } | |
| } | |
| setDependencies(BackendEventService, []); | |
| const IMessageService = createIdentifier("IMessageService"); | |
| class BrowserMessageService { | |
| constructor(eventService) { | |
| this.eventService = eventService; | |
| this.requestId = 0; | |
| this.handleBackendResponse = this.handleBackendResponse.bind(this); | |
| this.initializeListener(); | |
| } | |
| async call(type, payload) { | |
| const correlationId = `req-${this.requestId++}`; | |
| const command = { type, payload, correlationId, timestamp: new Date().toISOString() }; | |
| window.parent.sendMessage("frontend:message", command); | |
| return correlationId; | |
| } | |
| initializeListener() { | |
| window.addEventListener("message", this.handleBackendResponse); | |
| } | |
| handleBackendResponse(event) { | |
| if (event.data && event.data.type === "backendResponse") { | |
| try { | |
| const response = JSON.parse(event.data.data); | |
| this.eventService.publish("backendResponse", response); | |
| } catch (e) { | |
| console.error("Fatal error parsing backend response:", e, event.data.data); | |
| } | |
| } | |
| } | |
| dispose() { | |
| window.removeEventListener("message", this.handleBackendResponse); | |
| } | |
| } | |
| setDependencies(BrowserMessageService, [IBackendEventService]); | |
| // 2. FRAMEWORK: HIGH-LEVEL COMMAND ABSTRACTION | |
| const ICommandService = createIdentifier("ICommandService"); | |
| class CommandService { | |
| constructor(messageService, eventService) { | |
| this.messageService = messageService; | |
| this.pendingCommands = new Map(); | |
| this.RPC_TIMEOUT = 10000; | |
| this.handleBackendResponse = this.handleBackendResponse.bind(this); | |
| eventService.subscribe("backendResponse", this.handleBackendResponse); | |
| } | |
| execute = (type, payload) => { | |
| return new Promise(async (resolve, reject) => { | |
| const commandId = await this.messageService.call(type, payload); | |
| const timeoutId = setTimeout(() => { | |
| if (this.pendingCommands.has(commandId)) { | |
| this.pendingCommands.delete(commandId); | |
| reject(new Error(`Command '${type}' timed out.`)); | |
| } | |
| }, this.RPC_TIMEOUT); | |
| this.pendingCommands.set(commandId, { resolve, reject, timeoutId, type }); | |
| }); | |
| }; | |
| handleBackendResponse(response) { | |
| const { correlationId, taskId } = response; | |
| if (correlationId && this.pendingCommands.has(correlationId)) { | |
| // Sync RPC or Async Task Initiation | |
| const cmd = this.pendingCommands.get(correlationId); | |
| if (response.status === "success") { | |
| if (response.data && response.data.taskId) { | |
| // It's an async task starting. Transfer promise to the new taskId. | |
| const newTaskId = response.data.taskId; | |
| this.pendingCommands.delete(correlationId); // remove old correlationId | |
| this.pendingCommands.set(newTaskId, cmd); // re-map with taskId | |
| } else { | |
| // It's a normal sync RPC response. | |
| clearTimeout(cmd.timeoutId); | |
| this.pendingCommands.delete(correlationId); | |
| cmd.resolve(response.data); | |
| } | |
| } else { | |
| // Error on initiation | |
| clearTimeout(cmd.timeoutId); | |
| this.pendingCommands.delete(correlationId); | |
| cmd.reject(new Error(response.message || "An unknown backend error occurred.")); | |
| } | |
| } else if (taskId && this.pendingCommands.has(taskId)) { | |
| // Async Task Completion | |
| const cmd = this.pendingCommands.get(taskId); | |
| clearTimeout(cmd.timeoutId); // In case we want a total task timeout | |
| this.pendingCommands.delete(taskId); | |
| if (response.status === "success") { | |
| cmd.resolve(response.data); | |
| } else { | |
| cmd.reject(new Error(response.message || "Async task failed.")); | |
| } | |
| } | |
| } | |
| } | |
| setDependencies(CommandService, [IMessageService, IBackendEventService]); | |
| // 3. FRAMEWORK: UNIFIED REACT HOOK FOR UI COMPONENTS | |
| const useCommand = () => { | |
| const commandService = useDependency(ICommandService); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [error, setError] = useState(null); | |
| const [data, setData] = useState(null); | |
| const execute = useCallback(async (type, payload) => { | |
| setIsLoading(true); | |
| setError(null); | |
| setData(null); | |
| try { | |
| const result = await commandService.execute(type, payload); | |
| setData(result); | |
| return result; | |
| } catch (err) { | |
| setError(err.message); | |
| throw err; | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }, [commandService]); | |
| return { execute, isLoading, error, data }; | |
| }; | |
| // 4. FRAMEWORK: BOOTSTRAPPING AND VIEW MANAGEMENT | |
| const IView = createIdentifier("IView"); | |
| class ViewManagementService { | |
| constructor(views) { this.views = views; } | |
| getViews() { return this.views; } | |
| } | |
| setDependencies(ViewManagementService, [[new Many(), IView]]); | |
| const App = () => { | |
| const viewManager = useDependency(ViewManagementService); | |
| return ( | |
| <div className="p-4 max-w-5xl mx-auto bg-gray-50 shadow-lg rounded-lg flex flex-col h-[100vh]"> | |
| {viewManager.getViews().map((ViewComponent, index) => ( | |
| <Fragment key={index}><ViewComponent /></Fragment> | |
| ))} | |
| </div> | |
| ); | |
| }; | |
| const Alert = ({ message, type }) => { | |
| const baseClasses = "text-sm px-4 py-2 rounded-md"; | |
| const typeClasses = { | |
| success: "bg-green-100 text-green-800", | |
| error: "bg-red-100 text-red-800", | |
| }; | |
| return <div className={`${baseClasses} ${typeClasses[type]}`}>{message}</div>; | |
| }; | |
| // =================================================================== | |
| // =============== BUSINESS LOGIC CODE ======================= | |
| // =================================================================== | |
| // This section contains your feature-specific components (Views). | |
| // To add a new feature, add your new Component here and register | |
| // it as an `IView` in the IOC Configuration section below. | |
| // ------------------------------------------------------------------- | |
| const BridgeControlView = () => { | |
| const [serverState, setServerState] = useState({ status: 'unknown', port: null }); | |
| const { execute, isLoading, error } = useCommand(); | |
| const fetchStatus = useCallback(async () => { | |
| try { | |
| const data = await execute('SERVER_GET_STATUS', {}); | |
| setServerState(data); | |
| } catch (e) { setServerState({ status: 'error' }); } | |
| }, [execute]); | |
| useEffect(() => { fetchStatus(); }, [fetchStatus]); | |
| const handleToggle = async (action) => { | |
| try { | |
| const cmd = action === 'start' ? 'SERVER_START' : 'SERVER_STOP'; | |
| const finalState = await execute(cmd, {}); | |
| setServerState(finalState); | |
| } catch (e) { console.error(e); fetchStatus(); } | |
| }; | |
| const isRunning = serverState.status === 'running'; | |
| return ( | |
| <div className="p-6"> | |
| <h2 className="text-2xl font-bold text-gray-800 mb-4">Browser Inspector Bridge</h2> | |
| <div className="bg-blue-50 border border-blue-200 rounded p-4 mb-6"> | |
| <p className="text-blue-800 text-sm"> | |
| This server listens for requests from the browser UserScript to open elements in Studio Pro. | |
| <br/> | |
| <strong>Endpoint:</strong> http://localhost:{serverState.port || 5000}/open_in_studio_pro | |
| </p> | |
| </div> | |
| <div className="flex items-center justify-between bg-white p-4 rounded shadow-sm border border-gray-200 mb-6"> | |
| <div className="flex items-center space-x-3"> | |
| <div className={`w-4 h-4 rounded-full ${isRunning ? 'bg-green-500' : 'bg-red-500'}`}></div> | |
| <span className="font-medium text-gray-700"> | |
| {isRunning ? `Running on port ${serverState.port}` : 'Stopped'} | |
| </span> | |
| </div> | |
| <button onClick={fetchStatus} className="text-sm text-gray-500 hover:text-gray-700 underline"> | |
| Check Status | |
| </button> | |
| </div> | |
| <button | |
| onClick={() => handleToggle(isRunning ? 'stop' : 'start')} | |
| disabled={isLoading} | |
| className={`w-full py-3 rounded-lg font-semibold text-white transition-colors shadow-md | |
| ${isRunning | |
| ? 'bg-red-600 hover:bg-red-700' | |
| : 'bg-blue-600 hover:bg-blue-700'} | |
| ${isLoading ? 'opacity-50 cursor-not-allowed' : ''}`} | |
| > | |
| {isLoading ? 'Processing...' : (isRunning ? 'Stop Server' : 'Start Server')} | |
| </button> | |
| {error && <p className="text-red-600 mt-4 text-center">{error}</p>} | |
| </div> | |
| ); | |
| }; | |
| // =================================================================== | |
| // ============== IOC & APP INITIALIZATION =================== | |
| // =================================================================== | |
| const AppWithDependencies = connectDependencies(App, [ | |
| // --- Framework Registrations --- | |
| [ViewManagementService], | |
| [IBackendEventService, { useClass: BackendEventService }], | |
| [IMessageService, { useClass: BrowserMessageService }], | |
| [ICommandService, { useClass: CommandService }], | |
| // --- Business Logic Registrations --- | |
| // To add a new view, simply add another line here like the one below. | |
| [IView, { useValue: BridgeControlView }], | |
| ]); | |
| const root = ReactDOM.createRoot(document.getElementById("app")); | |
| root.render(<AppWithDependencies />); | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # region FRAMEWORK CODE | |
| import json | |
| import traceback | |
| from typing import Any, Dict, Callable, Iterable | |
| from abc import ABC, abstractmethod | |
| # Mendix and .NET related imports | |
| import clr | |
| clr.AddReference("System.Text.Json") | |
| clr.AddReference("Mendix.StudioPro.ExtensionsAPI") | |
| import threading | |
| import uuid | |
| from dependency_injector import containers, providers | |
| from System.Text.Json import JsonSerializer | |
| # ShowDevTools() | |
| # =================================================================== | |
| # =================== FRAMEWORK CODE ======================== | |
| # =================================================================== | |
| # This section contains the reusable, application-agnostic core. | |
| # You should not need to modify this section to add new features. | |
| # ------------------------------------------------------------------- | |
| # 1. FRAMEWORK: CORE ABSTRACTIONS AND INTERFACES | |
| class MendixEnvironmentService: | |
| """Abstracts the Mendix host environment global variables.""" | |
| def __init__(self, app_context, window_service, post_message_func: Callable): | |
| self.app = app_context | |
| self.window_service = window_service | |
| self.post_message = post_message_func | |
| def get_project_path(self) -> str: | |
| return self.app.Root.DirectoryPath | |
| class ICommandHandler(ABC): | |
| """Contract for all command handlers.""" | |
| @property | |
| @abstractmethod | |
| def command_type(self) -> str: | |
| """The command type string this handler responds to.""" | |
| pass | |
| @abstractmethod | |
| def execute(self, payload: Dict) -> Any: | |
| """Executes the business logic for the command.""" | |
| pass | |
| class IAsyncCommandHandler(ICommandHandler): | |
| """Extends ICommandHandler for tasks that should not block the main thread.""" | |
| @abstractmethod | |
| def execute_async(self, payload: Dict, task_id: str): | |
| """The logic to be executed in a background thread.""" | |
| pass | |
| # 2. FRAMEWORK: CENTRAL DISPATCHER | |
| class AppController: | |
| """Routes incoming frontend commands to the appropriate ICommandHandler.""" | |
| def __init__(self, handlers: Iterable[ICommandHandler], mendix_env: MendixEnvironmentService): | |
| self._mendix_env = mendix_env | |
| self._command_handlers = {h.command_type: h for h in handlers} | |
| self._mendix_env.post_message( | |
| "backend:info", f"Controller initialized with handlers for: {list(self._command_handlers.keys())}") | |
| def dispatch(self, request: Dict) -> Dict: | |
| command_type = request.get("type") | |
| payload = request.get("payload", {}) | |
| correlation_id = request.get("correlationId") | |
| try: | |
| handler = self._command_handlers.get(command_type) | |
| if not handler: | |
| raise ValueError(f"No handler found for command type: {command_type}") | |
| # Generic logic to handle sync vs. async handlers | |
| if isinstance(handler, IAsyncCommandHandler): | |
| task_id = f"task-{uuid.uuid4()}" | |
| thread = threading.Thread( | |
| target=handler.execute_async, | |
| args=(payload, task_id) | |
| ) | |
| thread.daemon = True | |
| thread.start() | |
| # The immediate response includes the taskId for frontend tracking | |
| result = handler.execute(payload) | |
| result['taskId'] = task_id | |
| return self._create_success_response(result, correlation_id) | |
| else: | |
| # Original synchronous execution path | |
| result = handler.execute(payload) | |
| return self._create_success_response(result, correlation_id) | |
| except Exception as e: | |
| error_message = f"Error executing command '{command_type}': {e}" | |
| self._mendix_env.post_message( | |
| "backend:info", f"{error_message}\n{traceback.format_exc()}") | |
| return self._create_error_response(error_message, correlation_id) | |
| def _create_success_response(self, data: Any, correlation_id: str) -> Dict: | |
| return {"status": "success", "data": data, "correlationId": correlation_id} | |
| def _create_error_response(self, message: str, correlation_id: str) -> Dict: | |
| return {"status": "error", "message": message, "correlationId": correlation_id} | |
| # endregion | |
| # region BUSINESS LOGIC CODE | |
| # =================================================================== | |
| # =============== BUSINESS LOGIC CODE ======================= | |
| # =================================================================== | |
| # This section contains your feature-specific command handlers. | |
| # To add a new feature, create a new class implementing ICommandHandler | |
| # or IAsyncCommandHandler, and register it in the Container below. | |
| # ------------------------------------------------------------------- | |
| # [REPLACED] Imports & Bridge Service | |
| import time | |
| import uvicorn | |
| from starlette.applications import Starlette | |
| from starlette.routing import Route | |
| from starlette.responses import JSONResponse, Response | |
| from starlette.requests import Request | |
| from typing import Optional | |
| # ========================================== | |
| # [HELPER] Mendix Document/Widget Finder | |
| # ========================================== | |
| class MendixFinder: | |
| @staticmethod | |
| def execute_open_logic(payload: Dict, app_root) -> bool: | |
| target_str = payload.get('target', '') | |
| if not target_str: return False | |
| parts = target_str.split('.') | |
| if len(parts) < 2: return False | |
| module_name = parts[0] | |
| unit_name = parts[1] | |
| widget_name = parts[2] if len(parts) > 2 else None | |
| # 1. Find Module | |
| module = next((m for m in app_root.GetModules() if m.Name == module_name), None) | |
| if not module: return False | |
| # 2. Find Document (Recursive) | |
| def find_doc(folder, name): | |
| doc = next((d for d in folder.GetDocuments() if d.Name == name), None) | |
| if doc: return doc | |
| for sub in folder.GetFolders(): | |
| res = find_doc(sub, name) | |
| if res: return res | |
| return None | |
| document = find_doc(module, unit_name) | |
| if not document: return document | |
| # 3. Find Widget (Recursive) | |
| target_element = None | |
| # 由于当前API不支持检索页面内部的IElement对象,跳过 | |
| # if widget_name: | |
| # def find_wid(node, name): | |
| # if getattr(node, "Name", None) == name: return node | |
| # if hasattr(node, "GetProperties"): | |
| # for p in node.GetProperties(): | |
| # if p.Value: | |
| # if p.IsList: | |
| # for item in p.Value: | |
| # res = find_wid(item, name) | |
| # if res: return res | |
| # elif hasattr(p.Value, "GetProperties"): | |
| # res = find_wid(p.Value, name) | |
| # if res: return res | |
| # return None | |
| # found = find_wid(document, widget_name) | |
| # if found: target_element = found | |
| # 4. Open Editor | |
| if 'dockingWindowService' in globals(): | |
| dockingWindowService.TryOpenEditor(document, target_element) | |
| return True | |
| return False | |
| class BridgeServerService: | |
| """Manages the Uvicorn server for Browser-StudioPro communication.""" | |
| def __init__(self, mendix_env: MendixEnvironmentService): | |
| self._mendix_env = mendix_env | |
| self._server: Optional[uvicorn.Server] = None | |
| self._server_thread: Optional[threading.Thread] = None | |
| self.port = 5000 # Matches UserScript default | |
| def is_running(self) -> bool: | |
| return self._server is not None and not self._server.should_exit | |
| def start(self): | |
| if self.is_running(): return | |
| async def handle_rpc(request: Request): | |
| headers = { | |
| "Access-Control-Allow-Origin": "*", | |
| "Access-Control-Allow-Methods": "POST, OPTIONS", | |
| "Access-Control-Allow-Headers": "Content-Type" | |
| } | |
| if request.method == "OPTIONS": | |
| return Response(status_code=204, headers=headers) | |
| try: | |
| payload = await request.json() | |
| # [新增] 退出指令处理 | |
| if payload.get("action") == "shutdown": | |
| if self._server: | |
| self._server.should_exit = True | |
| return JSONResponse({"status": "shutting_down"}, status_code=200, headers=headers) | |
| success = MendixFinder.execute_open_logic(payload, self._mendix_env.app.Root) | |
| return JSONResponse({"status": "success" if success else "failed"}, status_code=200, headers=headers) | |
| except Exception as e: | |
| return JSONResponse({"status": "error", "message": str(e)}, status_code=500, headers=headers) | |
| app = Starlette(routes=[ | |
| Route("/open_in_studio_pro", handle_rpc, methods=["POST", "OPTIONS"]) | |
| ]) | |
| config = uvicorn.Config(app, host="127.0.0.1", port=self.port, log_config=None) | |
| self._server = uvicorn.Server(config) | |
| self._server_thread = threading.Thread(target=self._server.run) | |
| self._server_thread.start() | |
| self._mendix_env.post_message("backend:info", f"Bridge Server started on port {self.port}") | |
| def stop(self): | |
| if not self.is_running(): return | |
| self._server.should_exit = True | |
| self._mendix_env.post_message("backend:info", "Bridge Server stopped manually.") | |
| def get_status(self) -> Dict: | |
| return {"status": "running" if self.is_running() else "stopped", "port": self.port} | |
| # [REPLACED] Command Handlers | |
| class StartServerCommandHandler(IAsyncCommandHandler): | |
| command_type = "SERVER_START" # Modified type | |
| def __init__(self, service: BridgeServerService, mendix_env: MendixEnvironmentService): | |
| self._service = service | |
| self._mendix_env = mendix_env | |
| def execute(self, payload: Dict) -> Dict: | |
| return {"status": "accepted"} | |
| def execute_async(self, payload: Dict, task_id: str): | |
| try: | |
| self._service.start() | |
| self._mendix_env.post_message("backend:response", json.dumps({ | |
| "taskId": task_id, "status": "success", "data": self._service.get_status() | |
| })) | |
| except Exception as e: | |
| self._mendix_env.post_message("backend:info", str(e)) | |
| class StopServerCommandHandler(IAsyncCommandHandler): | |
| command_type = "SERVER_STOP" # Modified type | |
| def __init__(self, service: BridgeServerService, mendix_env: MendixEnvironmentService): | |
| self._service = service | |
| self._mendix_env = mendix_env | |
| def execute(self, payload: Dict) -> Dict: | |
| return {"status": "accepted"} | |
| def execute_async(self, payload: Dict, task_id: str): | |
| self._service.stop() | |
| time.sleep(0.5) | |
| self._mendix_env.post_message("backend:response", json.dumps({ | |
| "taskId": task_id, "status": "success", "data": self._service.get_status() | |
| })) | |
| class GetStatusCommandHandler(ICommandHandler): | |
| command_type = "SERVER_GET_STATUS" | |
| def __init__(self, service: BridgeServerService): | |
| self._service = service | |
| def execute(self, payload: Dict) -> Dict: | |
| return self._service.get_status() | |
| # endregion | |
| # region IOC & APP INITIALIZATION | |
| # =================================================================== | |
| # ============== IOC & APP INITIALIZATION =================== | |
| # =================================================================== | |
| class Container(containers.DeclarativeContainer): | |
| """The application's Inversion of Control (IoC) container.""" | |
| config = providers.Configuration() | |
| # --- Framework Registrations --- | |
| mendix_env = providers.Singleton( | |
| MendixEnvironmentService, | |
| app_context=config.app_context, | |
| window_service=config.window_service, | |
| post_message_func=config.post_message_func, | |
| ) | |
| # [REPLACED] IoC Container | |
| # --- Business Logic Registrations --- | |
| bridge_service = providers.Singleton(BridgeServerService, mendix_env=mendix_env) | |
| command_handlers = providers.List( | |
| providers.Singleton(StartServerCommandHandler, service=bridge_service, mendix_env=mendix_env), | |
| providers.Singleton(StopServerCommandHandler, service=bridge_service, mendix_env=mendix_env), | |
| providers.Singleton(GetStatusCommandHandler, service=bridge_service), | |
| ) | |
| # --- Framework Controller (depends on handlers) --- | |
| app_controller = providers.Singleton( | |
| AppController, | |
| handlers=command_handlers, | |
| mendix_env=mendix_env, | |
| ) | |
| # --- Application Entrypoint and Wiring --- | |
| def onMessage(e: Any): | |
| """Entry point called by Mendix Studio Pro for messages from the UI.""" | |
| if e.Message != "frontend:message": | |
| return | |
| controller = container.app_controller() | |
| request_object = None | |
| try: | |
| request_string = JsonSerializer.Serialize(e.Data) | |
| request_object = json.loads(request_string) | |
| response = controller.dispatch(request_object) | |
| PostMessage("backend:response", json.dumps(response)) | |
| except Exception as ex: | |
| PostMessage("backend:info", f"Fatal error in onMessage: {ex}\n{traceback.format_exc()}") | |
| correlation_id = request_object.get("correlationId", "unknown") if request_object else "unknown" | |
| fatal_error_response = { | |
| "status": "error", | |
| "message": f"A fatal backend error occurred: {ex}", | |
| "correlationId": correlation_id | |
| } | |
| PostMessage("backend:response", json.dumps(fatal_error_response)) | |
| def initialize_app(): | |
| """Initializes the IoC container with the Mendix environment services.""" | |
| container = Container() | |
| container.config.from_dict({ | |
| "app_context": currentApp, | |
| "window_service": dockingWindowService, | |
| "post_message_func": PostMessage | |
| }) | |
| return container | |
| # --- Application Start --- | |
| import urllib.request | |
| import urllib.error | |
| def ensure_previous_instance_killed(port=5000): | |
| """ | |
| 尝试连接本地端口并发送关闭指令。 | |
| 如果端口通畅(旧服务在运行),旧服务收到指令后会退出。 | |
| 如果连接被拒绝(无服务运行),则跳过。 | |
| """ | |
| url = f"http://127.0.0.1:{port}/open_in_studio_pro" | |
| shutdown_payload = json.dumps({"action": "shutdown"}).encode('utf-8') | |
| try: | |
| # 尝试发送关闭指令 | |
| req = urllib.request.Request(url, data=shutdown_payload, headers={'Content-Type': 'application/json'}) | |
| with urllib.request.urlopen(req, timeout=1) as response: | |
| PostMessage("backend:info", f"Signal sent to existing instance on port {port}. Waiting for shutdown...") | |
| # 给予旧线程一点时间释放端口 | |
| time.sleep(1.5) | |
| except urllib.error.URLError: | |
| # 连接失败说明没有服务在运行,直接继续 | |
| pass | |
| except Exception as e: | |
| PostMessage("backend:info", f"Warning during cleanup check: {str(e)}") | |
| # 1. 先清理环境 | |
| PostMessage("backend:clear", '') | |
| # 2. 尝试关闭之前的实例(如果有) | |
| ensure_previous_instance_killed(5000) | |
| # 3. 初始化并启动新应用 | |
| container = initialize_app() | |
| PostMessage("backend:info", "Backend Python script initialized successfully.") | |
| # endregion |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "name": "MendixDevTool", | |
| "author": null, | |
| "email": null, | |
| "ui": "index.html", | |
| "plugin": "main.py" | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Mendix DevTool: Inspector & Opener | |
| // @namespace http://mendix.dev/ | |
| // @version 2.0 | |
| // @description Visual inspector for Mendix to open Layouts, Pages, and Widgets in Studio Pro via Python RPC. | |
| // @author Dev | |
| // @match http://localhost:8082/* | |
| // @grant GM_xmlhttpRequest | |
| // @grant GM_addStyle | |
| // @run-at document-end | |
| // ==/UserScript== | |
| //https://gist.github.com/engalar/9878bffebbaf74f977d9c2e134a7242b | |
| (function () { | |
| 'use strict'; | |
| //Configuration | |
| const RPC_URL = "http://localhost:5000/open_in_studio_pro"; | |
| // --- State Management --- | |
| let isInspectMode = false; | |
| let lockedContext = null; // Stores the context when user clicks to freeze selection | |
| // --- CSS Styles --- | |
| const styles = ` | |
| /* Control Button */ | |
| #mx-dev-toggle { | |
| position: fixed; bottom: 20px; right: 20px; z-index: 10000; | |
| width: 50px; height: 50px; border-radius: 50%; | |
| background: #444; color: white; border: 2px solid #fff; | |
| cursor: pointer; box-shadow: 0 4px 10px rgba(0,0,0,0.3); | |
| display: flex; align-items: center; justify-content: center; font-size: 24px; | |
| transition: background 0.3s; | |
| } | |
| #mx-dev-toggle.active { background: #264AE5; border-color: #61DAFB; } | |
| /* Information Panel */ | |
| #mx-dev-panel { | |
| position: fixed; top: 20px; right: 20px; z-index: 9999; | |
| width: 350px; background: rgba(30, 30, 30, 0.95); | |
| color: #eee; padding: 10px; border-radius: 8px; | |
| font-family: Consolas, monospace; font-size: 12px; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.5); | |
| display: none; pointer-events: auto; | |
| border: 1px solid #555; | |
| transition: top 0.2s, bottom 0.2s; /* 添加动画 */ | |
| } | |
| /* 当鼠标在右上角时,面板移到右下角 (Toggle按钮上方) */ | |
| #mx-dev-panel.moved { top: auto; bottom: 80px; } | |
| /* Header & Close Button */ | |
| .mx-panel-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #555; padding-bottom: 5px; margin-bottom: 5px; } | |
| .mx-panel-header h3 { margin: 0; font-size: 14px; color: #fff; } | |
| .mx-close-btn { cursor: pointer; font-size: 16px; color: #aaa; padding: 0 5px; } | |
| .mx-close-btn:hover { color: #fff; } | |
| /* Tree View Styles */ | |
| .mx-tree-ul { list-style: none; padding-left: 0; margin: 0; } | |
| .mx-tree-li { margin-left: 15px; border-left: 1px solid #555; } | |
| .mx-tree-item { | |
| padding: 4px 6px; display: flex; align-items: center; | |
| border-radius: 3px; margin-bottom: 2px; | |
| } | |
| .mx-tree-item:hover { background: #444; } | |
| .mx-tree-item.active { background: #264AE5; color: white; } | |
| .mx-tree-content { flex: 1; overflow: hidden; display: flex; align-items: center; cursor: pointer; } | |
| .mx-tree-type { font-size: 10px; opacity: 0.8; margin-right: 6px; min-width: 35px; text-transform: uppercase; font-weight: bold;} | |
| .mx-tree-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: 5px;} | |
| /* Copy Button */ | |
| .mx-copy-btn { | |
| background: transparent; border: 1px solid #666; color: #aaa; | |
| font-size: 10px; cursor: pointer; border-radius: 3px; padding: 0 4px; | |
| margin-left: auto; display: none; /* Show on hover */ | |
| } | |
| .mx-tree-item:hover .mx-copy-btn { display: block; } | |
| .mx-copy-btn:hover { background: #fff; color: #000; border-color: #fff; } | |
| /* Highlight Overlays */ | |
| .mx-dev-overlay { | |
| position: fixed; pointer-events: none; z-index: 9990; | |
| background: rgba(0,0,0,0.05); transition: all 0.1s ease-out; | |
| display: none; border-width: 2px; border-style: solid; | |
| } | |
| /* Info Label on Overlay */ | |
| .mx-overlay-info { | |
| position: absolute; top: -22px; left: -2px; | |
| background: rgba(0,0,0,0.8); color: white; | |
| padding: 2px 6px; font-size: 11px; border-radius: 3px 3px 0 0; | |
| white-space: nowrap; font-family: sans-serif; | |
| pointer-events: none; | |
| } | |
| .color-layout { border-color: #ff9800; background: rgba(255, 152, 0, 0.1); } | |
| .color-page { border-color: #4caf50; background: rgba(76, 175, 80, 0.1); } | |
| .color-widget { border-color: #2196f3; background: rgba(33, 150, 243, 0.1); } | |
| `; | |
| GM_addStyle(styles); | |
| function createUI() { | |
| // Toggle Button | |
| const toggle = document.createElement('div'); | |
| toggle.id = 'mx-dev-toggle'; | |
| toggle.innerHTML = '🔍'; | |
| // 修改:点击按钮在 开启/冻结 之间切换,或者重新开启 | |
| toggle.onclick = () => toggleInspectMode(); | |
| document.body.appendChild(toggle); | |
| // Tree Panel | |
| const panel = document.createElement('div'); | |
| panel.id = 'mx-dev-panel'; | |
| panel.innerHTML = ` | |
| <div class="mx-panel-header"> | |
| <h3>Page Structure</h3> | |
| <span class="mx-close-btn" title="Close Panel">✕</span> | |
| </div> | |
| <div id="mx-dev-tree-container" style="max-height: 400px; overflow-y: auto;"></div> | |
| <div style="margin-top:5px; font-size:10px; color:#888; border-top:1px solid #444; padding-top:5px;"> | |
| <span style="color:#4caf50">●</span> Page <span style="color:#2196f3">●</span> Widget<br> | |
| Left Click: Freeze & Edit | Copy btn: Copy ShortName | |
| </div> | |
| `; | |
| document.body.appendChild(panel); | |
| // 修改:点击关闭图标 -> 完全退出 | |
| panel.querySelector('.mx-close-btn').onclick = () => { | |
| toggleInspectMode('close'); | |
| }; | |
| // Overlays (保持不变) | |
| const div = document.createElement('div'); | |
| div.id = `mx-overlay-focus`; | |
| div.className = `mx-dev-overlay color-widget`; | |
| div.innerHTML = `<div class="mx-overlay-info"></div>`; | |
| document.body.appendChild(div); | |
| } | |
| function updateUI(treeData) { | |
| const container = document.getElementById('mx-dev-tree-container'); | |
| if (!container) return; | |
| container.innerHTML = ''; | |
| let currentContainer = document.createElement('ul'); | |
| currentContainer.className = 'mx-tree-ul'; | |
| container.appendChild(currentContainer); | |
| treeData.forEach(item => { | |
| const li = document.createElement('li'); | |
| li.className = 'mx-tree-li'; | |
| const div = document.createElement('div'); | |
| div.className = 'mx-tree-item'; | |
| if (item === treeData[treeData.length - 1]) div.classList.add('active'); | |
| // Copy Action | |
| const copyShortName = (e) => { | |
| e.stopPropagation(); | |
| navigator.clipboard.writeText(item.rpcTarget.split('.')[1]); | |
| const btn = e.target; | |
| btn.innerText = "OK"; | |
| setTimeout(() => btn.innerText = "Copy", 1000); | |
| }; | |
| // HTML Structure: Type + Name + Copy Button | |
| // item.rpcTarget is the Full Qualified Name (Module.Page.Widget) | |
| // item.displayName is the Short Name (Widget) | |
| div.innerHTML = ` | |
| <div class="mx-tree-content" title="${item.rpcTarget || item.fullId}"> | |
| <span class="mx-tree-type" style="color:${getTypeColor(item.type)}">${item.type}</span> | |
| <span class="mx-tree-name">${item.rpcTarget}</span> | |
| </div> | |
| <button class="mx-copy-btn">Copy</button> | |
| `; | |
| // Events | |
| const contentDiv = div.querySelector('.mx-tree-content'); | |
| contentDiv.onclick = (e) => { e.stopPropagation(); sendRpc(item.rpcTarget, item.type); highlightNode(item.node, item); }; | |
| contentDiv.onmouseenter = () => highlightNode(item.node, item); | |
| // Bind Copy | |
| div.querySelector('.mx-copy-btn').onclick = copyShortName; | |
| li.appendChild(div); | |
| const nextUl = document.createElement('ul'); | |
| nextUl.className = 'mx-tree-ul'; | |
| li.appendChild(nextUl); | |
| currentContainer.appendChild(li); | |
| currentContainer = nextUl; | |
| }); | |
| if (treeData.length > 0) { | |
| const lastItem = treeData[treeData.length - 1]; | |
| highlightNode(lastItem.node, lastItem); | |
| } | |
| } | |
| function getTypeColor(type) { | |
| if (type === 'layout') return '#ff9800'; | |
| if (type === 'page') return '#4caf50'; | |
| return '#2196f3'; | |
| } | |
| function highlightNode(node, itemData) { | |
| const overlay = document.getElementById(`mx-overlay-focus`); | |
| const infoLabel = overlay.querySelector('.mx-overlay-info'); | |
| if (node && node.nodeType === 1) { | |
| const rect = node.getBoundingClientRect(); | |
| if (rect.width > 0) { | |
| overlay.style.display = 'block'; | |
| overlay.style.top = rect.top + 'px'; | |
| overlay.style.left = rect.left + 'px'; | |
| overlay.style.width = rect.width + 'px'; | |
| overlay.style.height = rect.height + 'px'; | |
| // Update styling based on type | |
| overlay.className = `mx-dev-overlay color-${itemData ? itemData.type : 'widget'}`; | |
| // Update Info Label | |
| if (itemData) { | |
| infoLabel.innerHTML = `<span style="opacity:0.7">${itemData.type}:</span> <strong>${itemData.displayName}</strong>`; | |
| // Adjust label position if element is at top of screen | |
| if (rect.top < 25) { | |
| infoLabel.style.top = '100%'; // Move label to bottom | |
| infoLabel.style.bottom = 'auto'; | |
| infoLabel.style.borderRadius = '0 0 3px 3px'; | |
| } else { | |
| infoLabel.style.top = '-22px'; // Standard top | |
| infoLabel.style.bottom = 'auto'; | |
| infoLabel.style.borderRadius = '3px 3px 0 0'; | |
| } | |
| } | |
| return; | |
| } | |
| } | |
| overlay.style.display = 'none'; | |
| } | |
| // --- Core Logic: Traversal & Tree Building --- | |
| function analyzeContext(targetNode) { | |
| let current = targetNode; | |
| const path = []; | |
| let foundPagePrefix = null; | |
| // 1. 向上遍历收集节点 | |
| while (current && current !== document.body) { | |
| // A. 获取样式名 (作为备选名称) | |
| let mxName = null; | |
| if (current.classList) { | |
| current.classList.forEach(cls => { | |
| if (cls.startsWith('mx-name-')) mxName = cls.substring(8); | |
| }); | |
| } | |
| // B. 获取 Mendix ID | |
| let mendixId = current.getAttribute ? current.getAttribute('data-mendix-id') : null; | |
| if (mxName || mendixId) { | |
| let type = 'widget'; | |
| let shortName = mxName; // 短名称 (不含路径) | |
| // 逻辑修正:严格解析 ID | |
| if (mendixId) { | |
| // 去除 l. 或 p. 前缀,便于统一处理 | |
| const cleanId = mendixId.replace(/^[lp]\./, ''); | |
| const parts = cleanId.split('.'); | |
| // [关键修改] 始终只取最后一段作为组件名 | |
| // 无论 ID 是 "Mod.Page.Btn" 还是 "Mod.Page.Grid.Btn",都只取 "Btn" | |
| shortName = parts[parts.length - 1]; | |
| // 确定类型 | |
| if (mendixId.startsWith('l.')) type = 'layout'; | |
| else if (mendixId.startsWith('p.')) { | |
| type = 'page'; | |
| // 记录 Page 前缀 (Module.PageName) | |
| if (parts.length >= 2) foundPagePrefix = `${parts[0]}.${parts[1]}`; | |
| } else { | |
| // 如果是普通 Widget ID,尝试提取 Page 前缀 (备用) | |
| if (!foundPagePrefix && parts.length >= 2) { | |
| foundPagePrefix = `${parts[0]}.${parts[1]}`; | |
| } | |
| } | |
| } | |
| // 如果只有 mx-name,没有 mendixId,shortName 已经是正确的了 | |
| path.push({ | |
| type: type, | |
| displayName: shortName, // 树上显示 "badge1" | |
| fullId: mendixId, | |
| mxName: mxName, | |
| node: current | |
| }); | |
| } | |
| current = current.parentNode; | |
| } | |
| const tree = path.reverse(); | |
| // 2. 生成符合 Studio Pro 规范的 RPC 参数 | |
| tree.forEach(item => { | |
| if (item.type === 'widget') { | |
| if (foundPagePrefix) { | |
| // [关键修改] 强制格式:Module.Page.WidgetShortName | |
| // 忽略中间的容器路径,直接拼接 Page 前缀和组件短名称 | |
| item.rpcTarget = `${foundPagePrefix}.${item.displayName}`; | |
| } else { | |
| // 无法找到 Page 前缀时的兜底 (如 Popup 内部有时很难找) | |
| item.rpcTarget = item.fullId || item.displayName; | |
| } | |
| } else if (item.type === 'page') { | |
| // Page 直接用前缀 (Module.Page) | |
| item.rpcTarget = foundPagePrefix || parseMendixName(item.fullId); | |
| } else { | |
| // Layout 等其他情况 | |
| item.rpcTarget = item.fullId ? parseMendixName(item.fullId) : item.displayName; | |
| } | |
| }); | |
| return tree; | |
| } | |
| function parseMendixName(id) { | |
| const parts = id.split('.'); | |
| // 移除 l. 或 p. 前缀 | |
| if (parts[0] === 'l' || parts[0] === 'p') { | |
| parts.shift(); | |
| } | |
| // 返回 Qualified Name (Module.Name.SubName...) | |
| return parts.join('.'); | |
| } | |
| // --- Interaction Logic --- | |
| function handleMouseMove(e) { | |
| if (lockedContext) return; // 如果已锁定,不再随鼠标更新 | |
| // --- 新增逻辑:自动避让 --- | |
| const panel = document.getElementById('mx-dev-panel'); | |
| if (panel && panel.style.display !== 'none') { | |
| const winWidth = window.innerWidth; | |
| // 如果鼠标进入右上角区域 (面板宽度约350px + 边距,高度预估300px内) | |
| if (e.clientX > (winWidth - 400) && e.clientY < 400) { | |
| panel.classList.add('moved'); | |
| } else { | |
| panel.classList.remove('moved'); | |
| } | |
| } | |
| // ----------------------- | |
| const context = analyzeContext(e.target); | |
| updateUI(context); | |
| } | |
| function handleClick(e) { | |
| // 允许操作 DevTool 面板本身 | |
| if (e.target.closest('#mx-dev-panel') || e.target.closest('#mx-dev-toggle')) return; | |
| if (!isInspectMode) return; | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| // 核心修改:点击后进入"冻结"状态 (停止监听,但保留面板和高亮) | |
| toggleInspectMode(); // 不传参即为 Freeze | |
| } | |
| // 修改 toggleInspectMode 支持三种状态 | |
| function toggleInspectMode(action) { | |
| const btn = document.getElementById('mx-dev-toggle'); | |
| const panel = document.getElementById('mx-dev-panel'); | |
| // 如果传入 'start' 或者 (未指定且当前未在审查模式),则开启 | |
| if (action === 'start' || (action === undefined && !isInspectMode)) { | |
| isInspectMode = true; | |
| btn.classList.add('active'); | |
| panel.style.display = 'block'; | |
| document.body.style.cursor = 'crosshair'; | |
| // 重新开始时清除之前的树(可选,看你喜好) | |
| // document.getElementById('mx-dev-tree-container').innerHTML = ''; | |
| hideOverlays(); | |
| startListening(); | |
| } else { | |
| // 停止审查 (冻结状态) | |
| isInspectMode = false; | |
| btn.classList.remove('active'); | |
| document.body.style.cursor = 'default'; | |
| stopListening(); | |
| // 只有显式传入 'close' 时才隐藏面板 | |
| if (action === 'close') { | |
| panel.style.display = 'none'; | |
| hideOverlays(); | |
| } | |
| } | |
| } | |
| function handleKeyDown(e) { | |
| if (e.key === "Escape") { | |
| toggleInspectMode('close'); // ESC 视为完全退出 | |
| } | |
| } | |
| function hideOverlays() { | |
| const overlay = document.getElementById(`mx-overlay-focus`); | |
| if (overlay) overlay.style.display = 'none'; | |
| document.querySelectorAll('.mx-dev-overlay').forEach(el => el.style.display = 'none'); | |
| } | |
| function startListening() { | |
| document.addEventListener('mousemove', handleMouseMove, true); | |
| document.addEventListener('click', handleClick, true); | |
| document.addEventListener('keydown', handleKeyDown, true); // [新增] | |
| } | |
| function stopListening() { | |
| document.removeEventListener('mousemove', handleMouseMove, true); | |
| document.removeEventListener('click', handleClick, true); | |
| document.removeEventListener('keydown', handleKeyDown, true); // [新增] | |
| } | |
| function updatePanelRow(type, data) { | |
| const row = document.getElementById(`row-${type}`); | |
| const valSpan = row.querySelector('.mx-dev-val'); | |
| const btn = row.querySelector('button'); | |
| if (data) { | |
| valSpan.innerText = data.name; | |
| valSpan.style.color = '#fff'; | |
| btn.disabled = false; | |
| btn.style.opacity = 1; | |
| } else { | |
| valSpan.innerText = 'Not detected'; | |
| valSpan.style.color = '#666'; | |
| btn.disabled = true; | |
| btn.style.opacity = 0.5; | |
| } | |
| } | |
| function updateOverlayPosition(type, node) { | |
| const overlay = document.getElementById(`mx-overlay-${type}`); | |
| if (node && node.nodeType === 1) { // Ensure it's an element | |
| const rect = node.getBoundingClientRect(); | |
| // 防止 overlay 也是 0x0 | |
| if (rect.width > 0 && rect.height > 0) { | |
| overlay.style.display = 'block'; | |
| overlay.style.top = rect.top + 'px'; | |
| overlay.style.left = rect.left + 'px'; | |
| overlay.style.width = rect.width + 'px'; | |
| overlay.style.height = rect.height + 'px'; | |
| return; | |
| } | |
| } | |
| overlay.style.display = 'none'; | |
| } | |
| // --- RPC Communication --- | |
| function sendRpc(elementName, type) { | |
| if (!elementName) return; | |
| console.log(`[MendixRPC] Opening ${type}: ${elementName}`); | |
| GM_xmlhttpRequest({ | |
| method: "POST", | |
| url: RPC_URL, | |
| headers: { "Content-Type": "application/json" }, | |
| data: JSON.stringify({ | |
| target: elementName, | |
| type: type // 'layout', 'page', or 'widget' | |
| }), | |
| onload: function (res) { | |
| if (res.status === 200) { | |
| const btn = document.querySelector(`button.mx-copy-btn`); | |
| const originalText = btn.innerText; | |
| btn.innerText = "Opened!"; | |
| btn.style.background = "#4caf50"; | |
| setTimeout(() => { | |
| btn.innerText = originalText; | |
| btn.style.background = ""; | |
| }, 1000); | |
| } | |
| } | |
| }); | |
| } | |
| // Initialize | |
| setTimeout(createUI, 2000); // Wait for Mendix to hydrate | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment