feat(create): remaining viewers — dependencies, jobs, macros, approvals (#41)
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:
forbes
2026-02-18 18:46:06 -06:00
parent 29f4a7b110
commit bb14d7b0ef

View File

@@ -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: