Skip to content

Instantly share code, notes, and snippets.

@engalar
Last active September 3, 2025 05:26
Show Gist options
  • Save engalar/536df8e04cd5574946a62414abb12ad8 to your computer and use it in GitHub Desktop.
Save engalar/536df8e04cd5574946a62414abb12ad8 to your computer and use it in GitHub Desktop.
studio pro plugin for visualizer untyped model
<!-- gh gist edit 536df8e04cd5574946a62414abb12ad8 .\index.html -f index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Mendix Model Browser</title>
<script src="assets/react.development.js"></script>
<script src="assets/react-dom.development.js"></script>
<script src="assets/tailwindcss.js"></script>
<script src="assets/babel.min.js"></script>
<script src="assets/vconsole.min.js"></script>
<script>new VConsole();</script>
<style>
.copy-icon {
opacity: 0.2;
transition: opacity 0.2s;
cursor: pointer;
}
.group-hover:hover .copy-icon,
.copy-icon:hover {
opacity: 1;
}
.tooltip {
position: relative;
display: inline-block;
}
.tooltip .tooltiptext {
visibility: hidden;
width: 120px;
background-color: #555;
color: #fff;
text-align: center;
border-radius: 6px;
padding: 5px 0;
position: absolute;
z-index: 1;
bottom: 125%;
left: 50%;
margin-left: -60px;
opacity: 0;
transition: opacity 0.3s;
}
.tooltip .tooltiptext::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: #555 transparent transparent transparent;
}
.tooltip:hover .tooltiptext {
visibility: visible;
opacity: 1;
}
</style>
</head>
<body class="bg-gray-100 font-sans text-sm">
<div id="app"></div>
<script type="text/babel">
const { useState, useEffect, useCallback, useMemo } = React;
// --- 1. RPC Client ---
class MendixRpcClient {
constructor() {
this.requestId = 0;
this.pendingRequests = new Map();
window.addEventListener('message', (event) => {
if (event.data.type === 'backendResponse') this.handleResponse(event.data.data);
});
}
handleResponse(response) {
const data = typeof response === 'string' ? JSON.parse(response) : response;
const pendingRequest = this.pendingRequests.get(data.requestId);
if (pendingRequest) {
this.pendingRequests.delete(data.requestId);
if (data.error) pendingRequest.reject(new Error(data.error.message || data.error));
else pendingRequest.resolve(data.result);
}
}
call(method, params = {}) {
const requestId = ++this.requestId;
return new Promise((resolve, reject) => {
this.pendingRequests.set(requestId, { resolve, reject });
window.parent.sendMessage("frontend:message", { jsonrpc: "2.0", method, params, id: requestId });
});
}
getNodeChildren(nodeId) { return this.call('getNodeChildren', { nodeId }); }
getNodeDetails(nodeId) { return this.call('getNodeDetails', { nodeId }); }
getPropertyChildren(propertyId) { return this.call('getPropertyChildren', { propertyId }); }
}
const rpcClient = new MendixRpcClient();
// --- Helper function for copying to clipboard ---
function copyToClipboard(text, tooltipRef) {
navigator.clipboard.writeText(text).then(() => {
if (tooltipRef && tooltipRef.current) {
const originalText = tooltipRef.current.innerText;
tooltipRef.current.innerText = 'Copied!';
setTimeout(() => {
tooltipRef.current.innerText = originalText;
}, 1500);
}
}).catch(err => {
console.error('Failed to copy: ', err);
alert('Failed to copy text to clipboard.');
});
}
// --- 2. UI Components ---
function CopyButton({ textToCopy, tooltipText = "Copy path" }) {
const tooltipRef = React.useRef(null);
return (
<div className="tooltip" onClick={(e) => { e.stopPropagation(); copyToClipboard(textToCopy, tooltipRef); }}>
<span className="copy-icon ml-2">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</span>
<span className="tooltiptext" ref={tooltipRef}>{tooltipText}</span>
</div>
);
}
function CollapsibleSection({ title, count, children, defaultOpen = false, path }) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="mb-4">
<h3 onClick={() => setIsOpen(!isOpen)} className="group-hover font-semibold text-gray-700 border-b pb-1 mb-2 cursor-pointer flex justify-between items-center select-none">
<span className="flex items-center">
{title} ({count})
{path && <CopyButton textToCopy={path} />}
</span>
<span className="text-gray-500 text-xs transform transition-transform">{isOpen ? '▼' : '►'}</span>
</h3>
{isOpen && children}
</div>
);
}
function PropertyNode({ node, level = 0, parentPath = '' }) {
const [isExpanded, setIsExpanded] = useState(false);
const [children, setChildren] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const handleToggle = async () => {
if (!node.hasChildren) return;
const nextExpandedState = !isExpanded;
setIsExpanded(nextExpandedState);
if (nextExpandedState && children.length === 0) {
setIsLoading(true);
try {
let result = await rpcClient.getPropertyChildren(node.id);
setChildren(result);
} catch (e) {
alert(`Error fetching property children: ${e.message}`);
setIsExpanded(false);
} finally {
setIsLoading(false);
}
}
};
const isResolvableElement = node.valueType === 'ResolvableElement';
const currentPath = useMemo(() => {
// e.g., @['entities'][0] or @['entities'][Name='Account']
if (node.name.startsWith('[')) {
return `${parentPath}${node.name}`;
}
// e.g., @['entities'][Name='Account']['generalization']
return `${parentPath}['${node.name}']`;
}, [parentPath, node.name]);
return (
<div className="border-b hover:bg-gray-50 text-xs">
<div className="group-hover flex items-center" style={{ paddingLeft: `${level * 16}px` }} onClick={handleToggle}>
<div className={`p-1 flex items-center flex-grow ${node.hasChildren ? 'cursor-pointer' : ''}`}>
<span className="w-5 h-5 text-center text-gray-500 mr-1 flex-shrink-0">
{node.hasChildren ? (isExpanded ? '▼' : '►') : <span className="inline-block w-3"></span>}
</span>
<span className="font-mono text-gray-600 break-all w-2/5 pr-2">{node.name}</span>
<span className={`break-all w-2/5 pr-2 ${isResolvableElement ? 'italic text-purple-700' : ''}`}>
{node.value}
</span>
<span className="text-gray-500 break-all w-1/5">{node.type}</span>
</div>
<CopyButton textToCopy={currentPath} />
</div>
{isExpanded && (
<div>
{isLoading && <div style={{ paddingLeft: `${(level + 1) * 16}px` }} className="text-gray-400 italic p-1">Loading...</div>}
{children.map(child => <PropertyNode key={child.id} node={child} level={level + 1} parentPath={currentPath} />)}
</div>
)}
</div>
);
}
function InspectorPanel({ selectedNode, loading }) {
if (loading) return <div className="p-4 text-gray-500">Loading details...</div>;
if (!selectedNode || !selectedNode.details) return <div className="p-4 text-gray-500">Select a unit to see its details.</div>;
const { details } = selectedNode;
const { name, type, properties, elements } = details;
const groupedElements = elements.reduce((acc, el) => {
(acc[el.type] = acc[el.type] || []).push(el);
return acc;
}, {});
return (
<div className="p-3 bg-white h-full overflow-y-auto">
<h2 className="text-lg font-bold text-gray-800 break-all">{name}</h2>
<p className="text-xs text-blue-600 bg-blue-100 rounded px-2 py-0.5 inline-block mb-4">{type}</p>
<CollapsibleSection title="Internal Elements" count={elements.length} defaultOpen={false}>
{elements.length > 0 ? (
Object.entries(groupedElements).map(([elementType, elementList]) => {
// e.g., @DomainModels$Entity
const groupPath = `@${elementType}`;
// e.g., @DomainModels$Entity[Name='Account']['generalization']
const groupPathWithName = elementList.length === 1 ? `@${elementType}[Name='${elementList[0].name}']` : groupPath;
return (
<CollapsibleSection key={elementType} title={elementType} count={elementList.length} path={groupPath}>
{elementList.map((el) => {
const hasName = el.name && !el.name.startsWith('[');
// e.g., @DomainModels$Entity[Name='Account']
const elIdentifier = hasName ? `[Name='${el.name}']` : `[${el.name.replace(/[\[\]]/g, '')}]`;
const elPath = `${groupPath}${elIdentifier}`;
return <PropertyNode key={el.id} node={el} level={1} parentPath={elPath} />;
})}
</CollapsibleSection>
);
})
) : <p className="text-xs text-gray-400 italic">No internal elements found.</p>}
</CollapsibleSection>
<CollapsibleSection title="Properties" count={properties.length} defaultOpen={true}>
{properties.length > 0 ? (
<div className="w-full text-left text-xs">
<div className="border-b border-t flex bg-gray-50 sticky top-0">
<div className="font-semibold p-1 w-2/5 pl-8">Name</div>
<div className="font-semibold p-1 w-2/5">Value</div>
<div className="font-semibold p-1 w-1/5">Type</div>
</div>
<div>
{properties.map(prop => <PropertyNode key={prop.id} node={prop} parentPath="@" />)}
</div>
</div>
) : <p className="text-xs text-gray-400 italic">No properties found.</p>}
</CollapsibleSection>
</div>
);
}
function TreeNode({ node, onSelect, selectedNodeId, level = 0, parentPath = '' }) {
const [isExpanded, setIsExpanded] = useState(false);
const [children, setChildren] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const currentPath = useMemo(() => {
const namePart = node.name ? `[Name='${node.name}']` : `[${node.index ?? 0}]`;
if (node.isUnit) {
return `${parentPath}/${node.type}${namePart}`;
}
return `${parentPath}/${node.type}${namePart}`;
}, [node, parentPath]);
const handleToggle = async () => {
const nextExpandedState = !isExpanded;
setIsExpanded(nextExpandedState);
if (nextExpandedState && children.length === 0 && node.hasChildren) {
setIsLoading(true);
try {
const result = await rpcClient.getNodeChildren(node.id);
setChildren(result);
} catch (e) {
alert(`Error fetching children: ${e.message}`);
setIsExpanded(false);
} finally {
setIsLoading(false);
}
}
};
const isSelected = selectedNodeId === node.id;
const nodeTypeColor = node.isUnit ? "text-blue-700" : "text-gray-600";
const handleSelect = () => {
onSelect({ id: node.id, path: currentPath });
};
return (
<div style={{ paddingLeft: `${level * 16}px` }}>
<div onClick={handleSelect} className={`group-hover flex items-center p-1 rounded cursor-pointer ${isSelected ? 'bg-blue-200' : 'hover:bg-gray-200'}`}>
<button onClick={(e) => { e.stopPropagation(); handleToggle(); }} className="w-5 h-5 text-center text-gray-500 mr-1 flex-shrink-0" disabled={!node.hasChildren}>
{node.hasChildren ? (isExpanded ? '▼' : '►') : <span className="inline-block w-2"></span>}
</button>
<div className="flex-grow overflow-hidden flex items-center">
<span className="font-semibold truncate">{node.name}</span>
<span className={`ml-2 text-xs truncate ${nodeTypeColor}`}>({node.type})</span>
</div>
<CopyButton textToCopy={currentPath} />
</div>
{isExpanded && (
<div>
{isLoading && <div style={{ paddingLeft: `16px` }} className="text-gray-400 italic">Loading...</div>}
{children.map(child => <TreeNode key={child.id} node={child} onSelect={onSelect} selectedNodeId={selectedNodeId} level={level + 1} parentPath={currentPath} />)}
</div>
)}
</div>
);
}
function App() {
const [rootNodes, setRootNodes] = useState([]);
const [selectedNode, setSelectedNode] = useState(null);
const [detailsLoading, setDetailsLoading] = useState(false);
useEffect(() => { rpcClient.getNodeChildren('root').then(setRootNodes); }, []);
const handleSelectNode = useCallback((nodeInfo) => {
if (selectedNode && nodeInfo.id === selectedNode.id) return;
const newNodeState = { id: nodeInfo.id, path: nodeInfo.path, details: null };
setSelectedNode(newNodeState);
setDetailsLoading(true);
rpcClient.getNodeDetails(nodeInfo.id)
.then(details => {
setSelectedNode({ ...newNodeState, details });
})
.catch(e => alert(`Error getting details: ${e.message}`))
.finally(() => setDetailsLoading(false));
}, [selectedNode]);
return (
<div className="flex h-screen w-screen text-gray-800">
<div className="w-1/3 h-full border-r bg-white overflow-y-auto p-2">
<h1 className="text-xl font-bold p-2">Mendix Model Browser</h1>
{rootNodes.map(node => (
<TreeNode key={node.id} node={node} onSelect={handleSelectNode} selectedNodeId={selectedNode?.id} parentPath="/" />
))}
</div>
<div className="w-2/3 h-full overflow-hidden">
<InspectorPanel selectedNode={selectedNode} loading={detailsLoading} />
</div>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('app'));
root.render(<App />);
</script>
</body>
</html>
# gh gist edit 536df8e04cd5574946a62414abb12ad8 .\main.py -f main.py
# pip install pythonnet dependency-injector
# === 0. BOILERPLATE & IMPORTS ===
import clr
from dependency_injector import containers, providers
import json
from typing import Any, Dict, List, Protocol
import traceback
import inspect
clr.AddReference("Mendix.StudioPro.ExtensionsAPI")
# PostMessage("backend:clear", '') # 在开发时取消注释以清空日志
# ShowDevTools() # 在开发时取消注释以打开开发者工具
# === 1. CORE UTILITIES ===
def serialize_json_object(json_object: Any) -> str:
# 将.NET对象序列化为JSON字符串
import System.Text.Json
return System.Text.Json.JsonSerializer.Serialize(json_object)
def deserialize_json_string(json_string: str) -> Any:
# 将JSON字符串反序列化为Python对象
return json.loads(json_string)
def post_message(channel: str, message: str):
# 向前端发送消息的辅助函数
PostMessage(channel, message)
# === 2. MODEL BROWSER IMPLEMENTATION ===
TOP_LEVEL_UNIT_TYPES = [
'Projects$ProjectConversion',
'Settings$ProjectSettings',
'Texts$SystemTextCollection',
'Navigation$NavigationDocument',
'Security$ProjectSecurity',
'Projects$Module',
]
MODULE_SPECIAL_UNIT_TYPES = [
'DomainModels$DomainModel',
'Security$ModuleSecurity',
'Projects$ModuleSettings',
]
class ModelBrowserService:
"""
封装所有与模型浏览相关的后端逻辑。
"""
def __init__(self, root: Any):
self._root = root
self._element_cache = {}
def _serialize_element_summary(self, element: Any, parent_id: str, index: int) -> Dict[str, Any]:
is_unit = hasattr(element, "ID")
element_id = f"{parent_id}:_elements:{index}"
if is_unit:
element_id = str(element.ID)
self._element_cache[element_id] = element
has_children = (element.GetUnits().Count > 0) if is_unit else False
return {
"id": element_id,
"name": getattr(element, "Name", "[Unnamed]"),
"type": element.Type,
"isUnit": is_unit,
"hasChildren": has_children
}
def _get_element_by_id(self, element_id: str) -> Any:
if element_id in self._element_cache:
return self._element_cache[element_id]
raise Exception(f"Element with ID '{element_id}' not found in cache.")
def _serialize_property(self, prop: Any, parent_id: str) -> Dict[str, Any]:
"""将属性序列化,并包含懒加载所需的信息。"""
prop_id = f"{parent_id}:property:{prop.Name}"
prop_value = "N/A"
has_children = False
value_type = "Primitive"
prop_type_str = str(prop.Type)
try:
val = prop.Value
if val is None:
prop_value = "null"
elif prop.IsList:
has_children = val.Count > 0
prop_value = f"[{val.Count} items]"
value_type = "List"
if has_children:
self._element_cache[prop_id] = val
elif hasattr(val, "ID"): # 这是一个可展开的Unit或Element
has_children = True
prop_value = getattr(val, "Name", "[Unnamed]")
value_type = "Element"
self._element_cache[prop_id] = val
# --- 新增逻辑开始 ---
elif prop_type_str in ["ElementByName", "ElementByQualifiedName"] and isinstance(val, str):
has_children = False # 我们在这里不解析它,所以它没有子节点
prop_value = str(val)
value_type = "ResolvableElement" # 使用新的类型来标识
# --- 新增逻辑结束 ---
else:
prop_value = str(val)
except Exception:
prop_value = "[Error reading value]"
return {
"id": prop_id,
"name": prop.Name,
"type": prop_type_str if prop_type_str!='Element' or prop.IsList or val==None else f"Element({val.Type},{val.ID})", # 使用prop_type_str
"value": prop_value,
"hasChildren": has_children,
"valueType": value_type,
}
def get_node_children(self, node_id: str) -> List[Dict[str, Any]]:
children_units = []
if node_id == "root":
for unit_type in TOP_LEVEL_UNIT_TYPES:
children_units.extend(self._root.GetUnitsOfType(unit_type))
else:
target_unit = self._get_element_by_id(node_id)
all_descendants = list(target_unit.GetUnits())
if not all_descendants:
return []
all_descendant_ids = {str(u.ID) for u in all_descendants}
grandchildren_ids = set()
for descendant in all_descendants:
for grand in descendant.GetUnits():
grandchildren_ids.add(str(grand.ID))
direct_child_ids = all_descendant_ids - grandchildren_ids
direct_children = [u for u in all_descendants if str(
u.ID) in direct_child_ids]
if target_unit.Type == 'Projects$Module':
special_units = []
other_direct_children = []
special_unit_types_set = set(MODULE_SPECIAL_UNIT_TYPES)
for child in direct_children:
if child.Type in special_unit_types_set:
special_units.append(child)
else:
other_direct_children.append(child)
children_units.extend(special_units)
children_units.extend(other_direct_children)
else:
children_units.extend(direct_children)
serialized_children = [self._serialize_element_summary(
unit, node_id, i) for i, unit in enumerate(children_units)]
return sorted(serialized_children, key=lambda x: x['name'] or '')
def get_node_details(self, node_id: str) -> Dict[str, Any]:
"""获取单元详情,属性和内部元素都将形成可懒加载的树。"""
if node_id == "root":
return {"name": "Project Root", "type": "IModelRoot", "properties": [], "elements": []}
target_unit = self._get_element_by_id(node_id)
# --- 修改 elements_list 的生成方式 ---
elements_list = []
for i, element in enumerate(target_unit.GetElements()):
element_id = f"{node_id}:element:{i}"
self._element_cache[element_id] = element
# 当元素没有名字时,使用其类型和索引作为备用名
element_name = getattr(element, "Name", None)
if not element_name:
element_name = f"[{element.Type.split('$')[-1]} #{i}]"
elements_list.append({
"id": element_id,
"name": element_name,
"type": element.Type,
"value": "", # 值可以是空的,因为主要信息在其子属性中
"hasChildren": True, # 假定所有内部元素都有属性可以查看
"valueType": "Element",
})
# --- 修改结束 ---
properties_tree = [self._serialize_property(
prop, node_id) for prop in target_unit.GetProperties()]
return {
"id": node_id,
"name": getattr(target_unit, "Name", "[Unnamed]"),
"type": target_unit.Type,
"elements": sorted(elements_list, key=lambda x: x['name'] or ''),
"properties": sorted(properties_tree, key=lambda x: x['name'] or '')
}
def get_property_children(self, property_id: str) -> List[Dict[str, Any]]:
"""懒加载属性的子节点。"""
children = []
prop_value_obj = self._element_cache.get(property_id)
if prop_value_obj is None:
return []
# --- 修改开始 ---
# 检查它是否像一个列表( duck-typing: 检查是否有'__iter__' 和 'Count',并且不是字符串)
is_list_like = hasattr(prop_value_obj, '__iter__') and hasattr(prop_value_obj, 'Count') and not isinstance(prop_value_obj, str)
if is_list_like and not hasattr(prop_value_obj, 'ID'): # 确保它不是一个有.Count属性的Unit/Element
for i, item in enumerate(prop_value_obj):
child_id = f"{property_id}:item:{i}"
# 检查列表项是否为基本类型
if isinstance(item, (str, int, float, bool)):
children.append({
"id": child_id,
"name": f"[{i}]",
"type": type(item).__name__, # 显示 'str', 'int', etc.
"value": str(item), # 直接显示字符串的值
"hasChildren": False,
"valueType": "Primitive"
})
# 注意:基本类型不需要缓存,因为它们不能再被展开
else: # 否则,假定它是一个复杂对象
self._element_cache[child_id] = item
children.append({
"id": child_id,
"name": f"[{i}]",
"type": getattr(item, "Type", "Unknown"),
"value": getattr(item, "Name", "[Unnamed]"),
"hasChildren": hasattr(item, "ID") or (hasattr(item, "GetProperties") and any(item.GetProperties())),
"valueType": "Element"
})
elif hasattr(prop_value_obj, 'ID'): # 是一个Element/Unit
# 属性的值本身就是一个可展开的对象
for prop in prop_value_obj.GetProperties():
children.append(self._serialize_property(prop, property_id))
# --- 修改结束 ---
# 列表项通常按索引排序,所以这里可以不排序,或按'name'(即索引)排序
# return sorted(children, key=lambda x: x['name'] or '')
return children
# === 3. RPC & IoC Container ===
class IRpcModule(Protocol):
pass
class ModelBrowserRpcModule(IRpcModule):
def __init__(self, service: ModelBrowserService): self._service = service
def getNodeChildren(
self, nodeId: str = "root") -> List[Dict[str, Any]]: return self._service.get_node_children(nodeId)
def getNodeDetails(
self, nodeId: str) -> Dict[str, Any]: return self._service.get_node_details(nodeId)
def getPropertyChildren(
self, propertyId: str) -> List[Dict[str, Any]]: return self._service.get_property_children(propertyId)
class RpcDispatcher:
def __init__(self, modules: List[IRpcModule]):
self._methods: Dict[str, Any] = {}
for module_instance in modules:
for name, method in inspect.getmembers(module_instance, predicate=inspect.ismethod):
if not name.startswith('_'):
self._methods[name] = method
def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
method_name = request.get('method')
request_id = request.get('id')
if not method_name or method_name not in self._methods:
return {'jsonrpc': '2.0', 'error': {'message': f"Method '{method_name}' not found."}, 'requestId': request_id}
try:
params = request.get('params', {})
result = self._methods[method_name](**params)
return {'jsonrpc': '2.0', 'result': result, 'requestId': request_id}
except Exception as e:
error_message = f"Error in '{method_name}': {e}\n{traceback.format_exc()}"
PostMessage("backend:info", error_message)
return {'jsonrpc': '2.0', 'error': {'message': error_message}, 'requestId': request_id}
class AppContainer(containers.DeclarativeContainer):
config = providers.Configuration()
model_browser_service = providers.Singleton(
ModelBrowserService, root=config.mendix_root)
model_browser_module = providers.Singleton(
ModelBrowserRpcModule, service=model_browser_service)
rpc_modules = providers.List(model_browser_module)
dispatcher = providers.Singleton(RpcDispatcher, modules=rpc_modules)
# === 4. COMPOSITION ROOT ===
container = AppContainer()
container.config.mendix_root.from_value(root)
dispatcher_instance = container.dispatcher()
def onMessage(e: Any):
if e.Message == "frontend:message":
try:
message_data = deserialize_json_string(serialize_json_object(e))
request_object = message_data.get("Data")
if request_object:
response = dispatcher_instance.handle_request(request_object)
post_message("backend:response", json.dumps(response))
except Exception as ex:
PostMessage(
"backend:info", f"Fatal error in onMessage: {ex}\n{traceback.format_exc()}")
{
"name": "untype visualizer",
"author": null,
"email": null,
"ui": "index.html",
"plugin": "main.py"
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment