feat(create): remaining viewers — dependencies, jobs, macros, approvals (#41)
All checks were successful
Build and Test / build (pull_request) Successful in 31m3s
All checks were successful
Build and Test / build (pull_request) Successful in 31m3s
Add four viewer widgets for the remaining Silo tree node types: - SiloApprovalsViewer: ECO approval status cards with colored status icons, state badge, and Open in Silo Web UI button - SiloDependencyTable: QTableView with resolution status (checks open documents for matching part_uuid) - SiloJobViewer: YAML source editor with Edit/Lock toggle, monospace font, dirty tracking, and unsaved-changes guard - SiloMacroEditor: Python source editor with Run Now (exec in FreeCAD context), Save button, and dirty tracking Also extends the viewer factory with prefix-based routing for silo/jobs/*.yaml and silo/macros/*.py entries. Closes #41
This commit is contained in:
@@ -548,14 +548,450 @@ class SiloHistoryViewer(QtWidgets.QWidget):
|
||||
return card
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Approvals Viewer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_APPROVAL_STATUS_ICONS = {
|
||||
"approved": ("\u2713", "#a6e3a1"), # ✓ green
|
||||
"pending": ("\u25cb", "#cdd6f4"), # ○ text
|
||||
"rejected": ("\u2717", "#f38ba8"), # ✗ red
|
||||
}
|
||||
|
||||
|
||||
class SiloApprovalsViewer(QtWidgets.QWidget):
|
||||
"""Read-only ECO approval status from ``silo/approvals.json``."""
|
||||
|
||||
WINDOW_TITLE = "Silo \u2014 Approvals"
|
||||
|
||||
def __init__(self, obj, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName(f"SiloViewer_{obj.Name}")
|
||||
self._build_ui(obj.RawContent)
|
||||
|
||||
def _build_ui(self, raw_content):
|
||||
try:
|
||||
data = json.loads(raw_content)
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
outer = QtWidgets.QVBoxLayout(self)
|
||||
outer.setContentsMargins(16, 16, 16, 16)
|
||||
outer.setSpacing(12)
|
||||
|
||||
# Header: ECO ID + state badge
|
||||
header = QtWidgets.QHBoxLayout()
|
||||
eco_id = data.get("eco", "\u2014")
|
||||
title = QtWidgets.QLabel(eco_id)
|
||||
font = title.font()
|
||||
font.setPointSize(font.pointSize() + 2)
|
||||
font.setBold(True)
|
||||
title.setFont(font)
|
||||
header.addWidget(title)
|
||||
|
||||
state = data.get("state", "")
|
||||
if state:
|
||||
badge = QtWidgets.QLabel(state)
|
||||
style = _BADGE_STYLES.get(
|
||||
state.replace("pending_", ""),
|
||||
"background: #45475a; color: #cdd6f4;",
|
||||
)
|
||||
badge.setStyleSheet(
|
||||
f"QLabel {{ {style} border-radius: 4px; "
|
||||
f"padding: 1px 6px; font-size: 11px; }}"
|
||||
)
|
||||
header.addWidget(badge)
|
||||
|
||||
header.addStretch()
|
||||
outer.addLayout(header)
|
||||
|
||||
# Separator
|
||||
sep = QtWidgets.QFrame()
|
||||
sep.setFrameShape(QtWidgets.QFrame.HLine)
|
||||
sep.setFrameShadow(QtWidgets.QFrame.Sunken)
|
||||
outer.addWidget(sep)
|
||||
|
||||
# Approver cards
|
||||
approvers = data.get("approvers", [])
|
||||
if not approvers:
|
||||
outer.addWidget(QtWidgets.QLabel("No approval data available."))
|
||||
else:
|
||||
for approver in approvers:
|
||||
outer.addWidget(self._make_approver_card(approver))
|
||||
|
||||
# Open in web UI button
|
||||
silo_instance = ""
|
||||
try:
|
||||
import FreeCAD as _fc
|
||||
|
||||
doc = _fc.ActiveDocument
|
||||
if doc:
|
||||
manifest_obj = doc.getObject("SiloManifest")
|
||||
if manifest_obj and manifest_obj.RawContent:
|
||||
m = json.loads(manifest_obj.RawContent)
|
||||
silo_instance = m.get("silo_instance", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if silo_instance and eco_id != "\u2014":
|
||||
link_btn = QtWidgets.QPushButton("Open in Silo Web UI \u2192")
|
||||
link_btn.clicked.connect(
|
||||
lambda checked=False, url=f"{silo_instance}/eco/{eco_id}": _open_url(
|
||||
url
|
||||
)
|
||||
)
|
||||
outer.addWidget(link_btn)
|
||||
|
||||
outer.addStretch()
|
||||
|
||||
def _make_approver_card(self, approver):
|
||||
card = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(card)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
layout.setSpacing(2)
|
||||
|
||||
top = QtWidgets.QHBoxLayout()
|
||||
top.setSpacing(8)
|
||||
|
||||
status = approver.get("status", "pending")
|
||||
icon_char, icon_color = _APPROVAL_STATUS_ICONS.get(
|
||||
status, _APPROVAL_STATUS_ICONS["pending"]
|
||||
)
|
||||
icon_label = QtWidgets.QLabel(icon_char)
|
||||
icon_label.setStyleSheet(f"color: {icon_color}; font-weight: bold;")
|
||||
top.addWidget(icon_label)
|
||||
|
||||
user = approver.get("user", "")
|
||||
top.addWidget(QtWidgets.QLabel(f"<b>{user}</b>"))
|
||||
|
||||
role = approver.get("role", "")
|
||||
if role:
|
||||
role_label = QtWidgets.QLabel(role)
|
||||
role_label.setStyleSheet("color: #a6adc8;")
|
||||
top.addWidget(role_label)
|
||||
|
||||
top.addWidget(QtWidgets.QLabel(status))
|
||||
top.addStretch()
|
||||
layout.addLayout(top)
|
||||
|
||||
ts = approver.get("timestamp")
|
||||
if ts:
|
||||
ts_label = QtWidgets.QLabel(_format_value("created_at", ts))
|
||||
ts_label.setStyleSheet("color: #a6adc8; margin-left: 24px;")
|
||||
layout.addWidget(ts_label)
|
||||
|
||||
return card
|
||||
|
||||
|
||||
def _open_url(url):
|
||||
"""Open a URL in the system browser."""
|
||||
from PySide.QtCore import QUrl
|
||||
from PySide.QtGui import QDesktopServices
|
||||
|
||||
QDesktopServices.openUrl(QUrl(url))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependency Table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SiloDependencyTable(QtWidgets.QWidget):
|
||||
"""Table view of assembly dependencies from ``silo/dependencies.json``."""
|
||||
|
||||
WINDOW_TITLE = "Silo \u2014 Dependencies"
|
||||
|
||||
def __init__(self, obj, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName(f"SiloViewer_{obj.Name}")
|
||||
self._build_ui(obj.RawContent)
|
||||
|
||||
def _build_ui(self, raw_content):
|
||||
try:
|
||||
data = json.loads(raw_content)
|
||||
except Exception:
|
||||
data = {}
|
||||
links = data.get("links", [])
|
||||
|
||||
outer = QtWidgets.QVBoxLayout(self)
|
||||
outer.setContentsMargins(16, 16, 16, 16)
|
||||
outer.setSpacing(12)
|
||||
|
||||
# Header
|
||||
title = QtWidgets.QLabel("Assembly Dependencies")
|
||||
font = title.font()
|
||||
font.setPointSize(font.pointSize() + 2)
|
||||
font.setBold(True)
|
||||
title.setFont(font)
|
||||
outer.addWidget(title)
|
||||
|
||||
sep = QtWidgets.QFrame()
|
||||
sep.setFrameShape(QtWidgets.QFrame.HLine)
|
||||
sep.setFrameShadow(QtWidgets.QFrame.Sunken)
|
||||
outer.addWidget(sep)
|
||||
|
||||
if not links:
|
||||
outer.addWidget(QtWidgets.QLabel("No dependencies defined."))
|
||||
outer.addStretch()
|
||||
return
|
||||
|
||||
# Table
|
||||
from PySide.QtGui import QStandardItemModel, QStandardItem
|
||||
|
||||
table = QtWidgets.QTableView()
|
||||
model = QStandardItemModel(len(links), 5)
|
||||
model.setHorizontalHeaderLabels(["Label", "Part Number", "Rev", "Qty", "Local"])
|
||||
|
||||
for row, link in enumerate(links):
|
||||
label_item = QStandardItem(link.get("label", ""))
|
||||
label_item.setEditable(False)
|
||||
model.setItem(row, 0, label_item)
|
||||
|
||||
pn_item = QStandardItem(link.get("part_number", ""))
|
||||
pn_item.setEditable(False)
|
||||
model.setItem(row, 1, pn_item)
|
||||
|
||||
rev_item = QStandardItem(str(link.get("revision", "")))
|
||||
rev_item.setEditable(False)
|
||||
model.setItem(row, 2, rev_item)
|
||||
|
||||
qty_item = QStandardItem(str(link.get("quantity", "")))
|
||||
qty_item.setEditable(False)
|
||||
model.setItem(row, 3, qty_item)
|
||||
|
||||
# Resolution: check if UUID is in any open document
|
||||
resolved = _is_dependency_resolved(link.get("uuid", ""))
|
||||
status_item = QStandardItem("\u2713" if resolved else "\u2717")
|
||||
status_item.setEditable(False)
|
||||
model.setItem(row, 4, status_item)
|
||||
|
||||
table.setModel(model)
|
||||
table.horizontalHeader().setStretchLastSection(True)
|
||||
table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
|
||||
table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
|
||||
outer.addWidget(table, 1)
|
||||
|
||||
|
||||
def _is_dependency_resolved(uuid_str):
|
||||
"""Check if a dependency UUID corresponds to a locally open document."""
|
||||
if not uuid_str:
|
||||
return False
|
||||
try:
|
||||
for doc in FreeCAD.listDocuments().values():
|
||||
manifest_obj = doc.getObject("SiloManifest")
|
||||
if manifest_obj and manifest_obj.RawContent:
|
||||
m = json.loads(manifest_obj.RawContent)
|
||||
if m.get("part_uuid") == uuid_str:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job Viewer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SiloJobViewer(QtWidgets.QWidget):
|
||||
"""YAML source viewer for ``silo/jobs/*.yaml`` files."""
|
||||
|
||||
WINDOW_TITLE = "Silo \u2014 Job"
|
||||
|
||||
def __init__(self, obj, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName(f"SiloViewer_{obj.Name}")
|
||||
self._obj = obj
|
||||
self._build_ui(obj.RawContent, obj.Label)
|
||||
|
||||
def _build_ui(self, raw_content, label):
|
||||
outer = QtWidgets.QVBoxLayout(self)
|
||||
outer.setContentsMargins(16, 16, 16, 16)
|
||||
outer.setSpacing(12)
|
||||
|
||||
# Header
|
||||
header = QtWidgets.QHBoxLayout()
|
||||
title = QtWidgets.QLabel(label)
|
||||
font = title.font()
|
||||
font.setPointSize(font.pointSize() + 2)
|
||||
font.setBold(True)
|
||||
title.setFont(font)
|
||||
header.addWidget(title)
|
||||
header.addStretch()
|
||||
|
||||
self._edit_btn = QtWidgets.QPushButton("Edit")
|
||||
self._edit_btn.setCheckable(True)
|
||||
self._edit_btn.toggled.connect(self._on_edit_toggled)
|
||||
header.addWidget(self._edit_btn)
|
||||
outer.addLayout(header)
|
||||
|
||||
sep = QtWidgets.QFrame()
|
||||
sep.setFrameShape(QtWidgets.QFrame.HLine)
|
||||
sep.setFrameShadow(QtWidgets.QFrame.Sunken)
|
||||
outer.addWidget(sep)
|
||||
|
||||
# Source editor
|
||||
self._editor = QtWidgets.QPlainTextEdit()
|
||||
self._editor.setPlainText(raw_content)
|
||||
self._editor.setReadOnly(True)
|
||||
self._editor.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
|
||||
mono = self._editor.font()
|
||||
mono.setFamily("monospace")
|
||||
self._editor.setFont(mono)
|
||||
outer.addWidget(self._editor, 1)
|
||||
|
||||
# Button bar
|
||||
btn_bar = QtWidgets.QHBoxLayout()
|
||||
btn_bar.addStretch()
|
||||
self._save_btn = QtWidgets.QPushButton("Save")
|
||||
self._save_btn.setEnabled(False)
|
||||
self._save_btn.clicked.connect(self._on_save)
|
||||
btn_bar.addWidget(self._save_btn)
|
||||
outer.addLayout(btn_bar)
|
||||
|
||||
self._editor.textChanged.connect(self._on_text_changed)
|
||||
|
||||
def _on_edit_toggled(self, checked):
|
||||
self._editor.setReadOnly(not checked)
|
||||
self._edit_btn.setText("Lock" if checked else "Edit")
|
||||
|
||||
def _on_text_changed(self):
|
||||
if not self._editor.isReadOnly():
|
||||
self._obj.Proxy.mark_dirty()
|
||||
self._save_btn.setEnabled(True)
|
||||
|
||||
def _on_save(self):
|
||||
self._obj.RawContent = self._editor.toPlainText()
|
||||
self._obj.Proxy.clear_dirty()
|
||||
self._save_btn.setEnabled(False)
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self._obj.Proxy.is_dirty():
|
||||
reply = QtWidgets.QMessageBox.question(
|
||||
self,
|
||||
"Unsaved Changes",
|
||||
"Job definition has unsaved changes. Discard?",
|
||||
QtWidgets.QMessageBox.Discard | QtWidgets.QMessageBox.Cancel,
|
||||
QtWidgets.QMessageBox.Cancel,
|
||||
)
|
||||
if reply == QtWidgets.QMessageBox.Cancel:
|
||||
event.ignore()
|
||||
return
|
||||
self._obj.Proxy.clear_dirty()
|
||||
event.accept()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Macro Editor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SiloMacroEditor(QtWidgets.QWidget):
|
||||
"""Python source editor for ``silo/macros/*.py`` files."""
|
||||
|
||||
WINDOW_TITLE = "Silo \u2014 Macro"
|
||||
|
||||
def __init__(self, obj, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName(f"SiloViewer_{obj.Name}")
|
||||
self._obj = obj
|
||||
self._build_ui(obj.RawContent, obj.Label)
|
||||
|
||||
def _build_ui(self, raw_content, label):
|
||||
outer = QtWidgets.QVBoxLayout(self)
|
||||
outer.setContentsMargins(16, 16, 16, 16)
|
||||
outer.setSpacing(12)
|
||||
|
||||
# Header
|
||||
header = QtWidgets.QHBoxLayout()
|
||||
title = QtWidgets.QLabel(label)
|
||||
font = title.font()
|
||||
font.setPointSize(font.pointSize() + 2)
|
||||
font.setBold(True)
|
||||
title.setFont(font)
|
||||
header.addWidget(title)
|
||||
header.addStretch()
|
||||
outer.addLayout(header)
|
||||
|
||||
sep = QtWidgets.QFrame()
|
||||
sep.setFrameShape(QtWidgets.QFrame.HLine)
|
||||
sep.setFrameShadow(QtWidgets.QFrame.Sunken)
|
||||
outer.addWidget(sep)
|
||||
|
||||
# Source editor
|
||||
self._editor = QtWidgets.QPlainTextEdit()
|
||||
self._editor.setPlainText(raw_content)
|
||||
self._editor.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
|
||||
mono = self._editor.font()
|
||||
mono.setFamily("monospace")
|
||||
self._editor.setFont(mono)
|
||||
outer.addWidget(self._editor, 1)
|
||||
|
||||
self._editor.textChanged.connect(self._on_text_changed)
|
||||
|
||||
# Button bar
|
||||
btn_bar = QtWidgets.QHBoxLayout()
|
||||
btn_bar.addStretch()
|
||||
run_btn = QtWidgets.QPushButton("Run Now")
|
||||
run_btn.clicked.connect(self._on_run)
|
||||
btn_bar.addWidget(run_btn)
|
||||
self._save_btn = QtWidgets.QPushButton("Save")
|
||||
self._save_btn.setEnabled(False)
|
||||
self._save_btn.clicked.connect(self._on_save)
|
||||
btn_bar.addWidget(self._save_btn)
|
||||
outer.addLayout(btn_bar)
|
||||
|
||||
def _on_text_changed(self):
|
||||
self._obj.Proxy.mark_dirty()
|
||||
self._save_btn.setEnabled(True)
|
||||
|
||||
def _on_save(self):
|
||||
self._obj.RawContent = self._editor.toPlainText()
|
||||
self._obj.Proxy.clear_dirty()
|
||||
self._save_btn.setEnabled(False)
|
||||
|
||||
def _on_run(self):
|
||||
"""Execute the macro in FreeCAD's Python context."""
|
||||
code = self._editor.toPlainText()
|
||||
try:
|
||||
FreeCAD.Console.PrintMessage(f"--- Running macro: {self._obj.Label} ---\n")
|
||||
exec(code, {"__builtins__": __builtins__, "FreeCAD": FreeCAD})
|
||||
except Exception as exc:
|
||||
FreeCAD.Console.PrintError(f"Macro error: {exc}\n")
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self._obj.Proxy.is_dirty():
|
||||
reply = QtWidgets.QMessageBox.question(
|
||||
self,
|
||||
"Unsaved Changes",
|
||||
"Macro has unsaved changes. Discard?",
|
||||
QtWidgets.QMessageBox.Discard | QtWidgets.QMessageBox.Cancel,
|
||||
QtWidgets.QMessageBox.Cancel,
|
||||
)
|
||||
if reply == QtWidgets.QMessageBox.Cancel:
|
||||
event.ignore()
|
||||
return
|
||||
self._obj.Proxy.clear_dirty()
|
||||
event.accept()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Viewer factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Exact path → viewer class
|
||||
_VIEWER_REGISTRY = {
|
||||
"silo/manifest.json": SiloManifestViewer,
|
||||
"silo/metadata.json": SiloMetadataEditor,
|
||||
"silo/history.json": SiloHistoryViewer,
|
||||
"silo/approvals.json": SiloApprovalsViewer,
|
||||
"silo/dependencies.json": SiloDependencyTable,
|
||||
}
|
||||
|
||||
# Prefix → viewer class (for subdirectory entries)
|
||||
_VIEWER_PREFIX_REGISTRY = {
|
||||
"silo/jobs/": SiloJobViewer,
|
||||
"silo/macros/": SiloMacroEditor,
|
||||
}
|
||||
|
||||
|
||||
@@ -566,6 +1002,11 @@ def create_viewer_widget(obj):
|
||||
is registered for this node's SiloPath.
|
||||
"""
|
||||
cls = _VIEWER_REGISTRY.get(obj.SiloPath)
|
||||
if cls is None:
|
||||
for prefix, prefix_cls in _VIEWER_PREFIX_REGISTRY.items():
|
||||
if obj.SiloPath.startswith(prefix):
|
||||
cls = prefix_cls
|
||||
break
|
||||
if cls is None:
|
||||
return None
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user