Compare commits

...

6 Commits

Author SHA1 Message Date
Zoe Forbes
d7c6066030 feat: live activity panel with SSE event feed and relative timestamps
Replace static item list refresh with real-time event feed:
- Add _relative_time() helper for human-friendly timestamps
- Prepend SSE events (item updates, new revisions, mode changes) instantly
- Seed feed with 10 recent items on first SSE connect (no per-item revision calls)
- Refresh relative timestamps every 60 seconds
- Cap activity feed at 50 events
- Remove expensive list_items + get_revisions calls on every SSE event
2026-02-12 17:27:25 -06:00
91f539a18a Merge pull request 'feat(open): replace modal open dialog with MDI tab' (#20) from feat/open-item-mdi-tab into main
Reviewed-on: #20
2026-02-12 17:47:09 +00:00
2ddfea083a Merge branch 'main' into feat/open-item-mdi-tab 2026-02-12 17:46:57 +00:00
be8783bf0a feat(open): replace modal open dialog with MDI tab
Extract search-and-open UI into OpenItemWidget (open_search.py), a
plain QWidget with item_selected/cancelled signals.  Silo_Open now
adds this widget as an MDI subwindow instead of running a blocking
QDialog, matching the Silo_New tab pattern.

Improvements over the old dialog:
- Non-blocking: multiple search tabs can be open simultaneously
- 500 ms debounce on search input reduces API load
- Filter checkbox changes trigger immediate re-search
2026-02-12 10:22:42 -06:00
069bb7a552 Merge pull request 'fix: pull assembly dependencies recursively before opening' (#19) from fix/pull-assembly-dependencies into main
Reviewed-on: #19
2026-02-11 19:12:22 +00:00
8a6e5cdffa fix: pull assembly dependencies recursively before opening
When pulling an assembly from Silo, the linked component files were not
downloaded, causing FreeCAD to report 'Link not restored' errors for
every external reference.

Add _pull_dependencies() that queries the BOM API to discover child
part numbers, then downloads the latest file revision for each child
that doesn't already exist locally. Recurses into sub-assemblies.

Silo_Pull.Activated() now calls _pull_dependencies() after downloading
the assembly file and before opening it, so all PropertyXLink paths
resolve correctly.
2026-02-11 13:09:59 -06:00
2 changed files with 486 additions and 236 deletions

191
freecad/open_search.py Normal file
View File

@@ -0,0 +1,191 @@
"""Search-and-open widget for Kindred Create.
Provides :class:`OpenItemWidget`, a plain ``QWidget`` that can be
embedded in an MDI sub-window. Searches both the Silo database and
local CAD files, presenting results in a unified table. Emits
``item_selected`` when the user picks an item and ``cancelled`` when
the user clicks Cancel.
"""
import FreeCAD
from PySide import QtCore, QtWidgets
class OpenItemWidget(QtWidgets.QWidget):
"""Search-and-open widget for embedding in an MDI subwindow.
Parameters
----------
client : SiloClient
Authenticated Silo API client instance.
search_local_fn : callable
Function that accepts a search term string and returns an
iterable of dicts with keys ``part_number``, ``description``,
``path``, ``modified``.
parent : QWidget, optional
Parent widget.
Signals
-------
item_selected(dict)
Emitted when the user selects an item. The dict contains
keys: *part_number*, *description*, *item_type*, *source*
(``"database"``, ``"local"``, or ``"both"``), *modified*,
and *path* (str or ``None``).
cancelled()
Emitted when the user clicks Cancel.
"""
item_selected = QtCore.Signal(dict)
cancelled = QtCore.Signal()
def __init__(self, client, search_local_fn, parent=None):
super().__init__(parent)
self._client = client
self._search_local = search_local_fn
self._results_data = []
self.setMinimumWidth(700)
self.setMinimumHeight(500)
self._build_ui()
# Debounced search timer (500 ms)
self._search_timer = QtCore.QTimer(self)
self._search_timer.setSingleShot(True)
self._search_timer.setInterval(500)
self._search_timer.timeout.connect(self._do_search)
# Populate on first display
QtCore.QTimer.singleShot(0, self._do_search)
# ---- UI construction ---------------------------------------------------
def _build_ui(self):
layout = QtWidgets.QVBoxLayout(self)
layout.setSpacing(8)
# Search row
self._search_input = QtWidgets.QLineEdit()
self._search_input.setPlaceholderText("Search by part number or description...")
self._search_input.textChanged.connect(self._on_search_changed)
layout.addWidget(self._search_input)
# Filter checkboxes
filter_layout = QtWidgets.QHBoxLayout()
self._db_checkbox = QtWidgets.QCheckBox("Database")
self._db_checkbox.setChecked(True)
self._local_checkbox = QtWidgets.QCheckBox("Local Files")
self._local_checkbox.setChecked(True)
self._db_checkbox.toggled.connect(self._on_filter_changed)
self._local_checkbox.toggled.connect(self._on_filter_changed)
filter_layout.addWidget(self._db_checkbox)
filter_layout.addWidget(self._local_checkbox)
filter_layout.addStretch()
layout.addLayout(filter_layout)
# Results table
self._results_table = QtWidgets.QTableWidget()
self._results_table.setColumnCount(5)
self._results_table.setHorizontalHeaderLabels(
["Part Number", "Description", "Type", "Source", "Modified"]
)
self._results_table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self._results_table.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
self._results_table.horizontalHeader().setStretchLastSection(True)
self._results_table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
self._results_table.doubleClicked.connect(self._open_selected)
layout.addWidget(self._results_table, 1)
# Buttons
btn_layout = QtWidgets.QHBoxLayout()
btn_layout.addStretch()
open_btn = QtWidgets.QPushButton("Open")
open_btn.clicked.connect(self._open_selected)
cancel_btn = QtWidgets.QPushButton("Cancel")
cancel_btn.clicked.connect(self.cancelled.emit)
btn_layout.addWidget(open_btn)
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)
# ---- Search logic ------------------------------------------------------
def _on_search_changed(self, _text):
"""Restart debounce timer on each keystroke."""
self._search_timer.start()
def _on_filter_changed(self, _checked):
"""Re-run search immediately when filter checkboxes change."""
self._do_search()
def _do_search(self):
"""Execute search against database and/or local files."""
search_term = self._search_input.text().strip()
self._results_data = []
if self._db_checkbox.isChecked():
try:
for item in self._client.list_items(search=search_term):
self._results_data.append(
{
"part_number": item.get("part_number", ""),
"description": item.get("description", ""),
"item_type": item.get("item_type", ""),
"source": "database",
"modified": item.get("updated_at", "")[:10]
if item.get("updated_at")
else "",
"path": None,
}
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"DB search failed: {e}\n")
if self._local_checkbox.isChecked():
try:
for item in self._search_local(search_term):
existing = next(
(r for r in self._results_data if r["part_number"] == item["part_number"]),
None,
)
if existing:
existing["source"] = "both"
existing["path"] = item.get("path")
else:
self._results_data.append(
{
"part_number": item.get("part_number", ""),
"description": item.get("description", ""),
"item_type": "",
"source": "local",
"modified": item.get("modified", "")[:10]
if item.get("modified")
else "",
"path": item.get("path"),
}
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Local search failed: {e}\n")
self._populate_table()
def _populate_table(self):
"""Refresh the results table from ``_results_data``."""
self._results_table.setRowCount(len(self._results_data))
for row, data in enumerate(self._results_data):
self._results_table.setItem(row, 0, QtWidgets.QTableWidgetItem(data["part_number"]))
self._results_table.setItem(row, 1, QtWidgets.QTableWidgetItem(data["description"]))
self._results_table.setItem(row, 2, QtWidgets.QTableWidgetItem(data["item_type"]))
self._results_table.setItem(row, 3, QtWidgets.QTableWidgetItem(data["source"]))
self._results_table.setItem(row, 4, QtWidgets.QTableWidgetItem(data["modified"]))
self._results_table.resizeColumnsToContents()
# ---- Selection ---------------------------------------------------------
def _open_selected(self):
"""Emit ``item_selected`` with the data from the selected row."""
selected = self._results_table.selectedItems()
if not selected:
return
row = selected[0].row()
self.item_selected.emit(dict(self._results_data[row]))

View File

@@ -7,6 +7,7 @@ import socket
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
@@ -26,7 +27,28 @@ from silo_client import (
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
# Configuration - preferences take priority over env vars
SILO_PROJECTS_DIR = os.environ.get("SILO_PROJECTS_DIR", os.path.expanduser("~/projects"))
SILO_PROJECTS_DIR = os.environ.get(
"SILO_PROJECTS_DIR", os.path.expanduser("~/projects")
)
def _relative_time(dt):
"""Format a datetime as a human-friendly relative string."""
now = datetime.now()
diff = now - dt
seconds = int(diff.total_seconds())
if seconds < 60:
return "just now"
minutes = seconds // 60
if minutes < 60:
return f"{minutes}m ago"
hours = minutes // 60
if hours < 24:
return f"{hours}h ago"
days = hours // 24
if days < 30:
return f"{days}d ago"
return dt.strftime("%Y-%m-%d")
# ---------------------------------------------------------------------------
@@ -64,7 +86,9 @@ class FreeCADSiloSettings(SiloSettings):
param = FreeCAD.ParamGet(_PREF_GROUP)
return param.GetString("SslCertPath", "")
def save_auth(self, username: str, role: str = "", source: str = "", token: str = ""):
def save_auth(
self, username: str, role: str = "", source: str = "", token: str = ""
):
param = FreeCAD.ParamGet(_PREF_GROUP)
param.SetString("AuthUsername", username)
param.SetString("AuthRole", role)
@@ -122,7 +146,9 @@ def _get_ssl_verify() -> bool:
def _get_ssl_context():
from silo_client._ssl import build_ssl_context
return build_ssl_context(_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path())
return build_ssl_context(
_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path()
)
def _get_auth_headers() -> Dict[str, str]:
@@ -179,7 +205,9 @@ def _fetch_server_mode() -> str:
# Icon helper
# ---------------------------------------------------------------------------
_ICON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "icons")
_ICON_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "resources", "icons"
)
def _icon(name):
@@ -574,7 +602,9 @@ def handle_kindred_url(url: str):
parts = [parsed.netloc] + [p for p in parsed.path.split("/") if p]
if len(parts) >= 2 and parts[0] == "item":
part_number = parts[1]
FreeCAD.Console.PrintMessage(f"Silo: Opening item {part_number} from kindred:// URL\n")
FreeCAD.Console.PrintMessage(
f"Silo: Opening item {part_number} from kindred:// URL\n"
)
_sync.open_item(part_number)
@@ -594,143 +624,32 @@ class Silo_Open:
}
def Activated(self):
from PySide import QtCore, QtGui
from open_search import OpenItemWidget
from PySide import QtGui, QtWidgets
dialog = QtGui.QDialog()
dialog.setWindowTitle("Silo - Open Item")
dialog.setMinimumWidth(700)
dialog.setMinimumHeight(500)
mw = FreeCADGui.getMainWindow()
mdi = mw.findChild(QtWidgets.QMdiArea)
if not mdi:
return
layout = QtGui.QVBoxLayout(dialog)
widget = OpenItemWidget(_client, search_local_files)
# Search row
search_layout = QtGui.QHBoxLayout()
search_input = QtGui.QLineEdit()
search_input.setPlaceholderText("Search by part number or description...")
search_layout.addWidget(search_input)
layout.addLayout(search_layout)
sw = mdi.addSubWindow(widget)
sw.setWindowTitle("Open Item")
sw.setWindowIcon(QtGui.QIcon(_icon("open")))
sw.show()
mdi.setActiveSubWindow(sw)
# Filters
filter_layout = QtGui.QHBoxLayout()
db_checkbox = QtGui.QCheckBox("Database")
db_checkbox.setChecked(True)
local_checkbox = QtGui.QCheckBox("Local Files")
local_checkbox.setChecked(True)
filter_layout.addWidget(db_checkbox)
filter_layout.addWidget(local_checkbox)
filter_layout.addStretch()
layout.addLayout(filter_layout)
# Results table
results_table = QtGui.QTableWidget()
results_table.setColumnCount(5)
results_table.setHorizontalHeaderLabels(
["Part Number", "Description", "Type", "Source", "Modified"]
)
results_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
results_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
results_table.horizontalHeader().setStretchLastSection(True)
results_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
layout.addWidget(results_table)
results_data = []
def do_search():
nonlocal results_data
search_term = search_input.text().strip()
results_data = []
results_table.setRowCount(0)
if db_checkbox.isChecked():
try:
for item in _client.list_items(search=search_term):
results_data.append(
{
"part_number": item.get("part_number", ""),
"description": item.get("description", ""),
"item_type": item.get("item_type", ""),
"source": "database",
"modified": item.get("updated_at", "")[:10]
if item.get("updated_at")
else "",
"path": None,
}
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"DB search failed: {e}\n")
if local_checkbox.isChecked():
try:
for item in search_local_files(search_term):
existing = next(
(r for r in results_data if r["part_number"] == item["part_number"]),
None,
)
if existing:
existing["source"] = "both"
existing["path"] = item.get("path")
else:
results_data.append(
{
"part_number": item.get("part_number", ""),
"description": item.get("description", ""),
"item_type": "",
"source": "local",
"modified": item.get("modified", "")[:10]
if item.get("modified")
else "",
"path": item.get("path"),
}
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Local search failed: {e}\n")
results_table.setRowCount(len(results_data))
for row, data in enumerate(results_data):
results_table.setItem(row, 0, QtGui.QTableWidgetItem(data["part_number"]))
results_table.setItem(row, 1, QtGui.QTableWidgetItem(data["description"]))
results_table.setItem(row, 2, QtGui.QTableWidgetItem(data["item_type"]))
results_table.setItem(row, 3, QtGui.QTableWidgetItem(data["source"]))
results_table.setItem(row, 4, QtGui.QTableWidgetItem(data["modified"]))
results_table.resizeColumnsToContents()
_open_after_close = [None]
def open_selected():
selected = results_table.selectedItems()
if not selected:
return
row = selected[0].row()
_open_after_close[0] = dict(results_data[row])
dialog.accept()
search_input.textChanged.connect(lambda: do_search())
results_table.doubleClicked.connect(open_selected)
# Buttons
btn_layout = QtGui.QHBoxLayout()
open_btn = QtGui.QPushButton("Open")
open_btn.clicked.connect(open_selected)
cancel_btn = QtGui.QPushButton("Cancel")
cancel_btn.clicked.connect(dialog.reject)
btn_layout.addStretch()
btn_layout.addWidget(open_btn)
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)
do_search()
dialog.exec_()
# Open the document AFTER the dialog has fully closed so that
# heavy document loads (especially Assembly files) don't run
# inside the dialog's nested event loop, which can cause crashes.
data = _open_after_close[0]
if data is not None:
def _on_selected(data):
sw.close()
if data.get("path"):
FreeCAD.openDocument(data["path"])
else:
_sync.open_item(data["part_number"])
widget.item_selected.connect(_on_selected)
widget.cancelled.connect(sw.close)
def IsActive(self):
return True
@@ -753,7 +672,6 @@ class Silo_New:
def Activated(self):
from PySide import QtGui, QtWidgets
from schema_form import SchemaFormWidget
mw = FreeCADGui.getMainWindow()
@@ -793,7 +711,9 @@ class Silo_New:
},
)
obj.Label = part_number
_sync.save_to_canonical_path(FreeCAD.ActiveDocument, force_rename=True)
_sync.save_to_canonical_path(
FreeCAD.ActiveDocument, force_rename=True
)
else:
_sync.create_document_for_item(result, save=True)
@@ -874,7 +794,9 @@ class Silo_Save:
# Try to upload to MinIO
try:
result = _client._upload_file(part_number, str(file_path), properties, "Auto-save")
result = _client._upload_file(
part_number, str(file_path), properties, "Auto-save"
)
new_rev = result["revision_number"]
FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n")
@@ -907,7 +829,9 @@ class Silo_Commit:
obj = get_tracked_object(doc)
if not obj:
FreeCAD.Console.PrintError("No tracked object. Use 'New' to register first.\n")
FreeCAD.Console.PrintError(
"No tracked object. Use 'New' to register first.\n"
)
return
part_number = obj.SiloPartNumber
@@ -924,7 +848,9 @@ class Silo_Commit:
if not file_path:
return
result = _client._upload_file(part_number, str(file_path), properties, comment)
result = _client._upload_file(
part_number, str(file_path), properties, comment
)
new_rev = result["revision_number"]
FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n")
@@ -973,7 +899,9 @@ def _check_pull_conflicts(part_number, local_path, doc=None):
server_updated = item.get("updated_at", "")
if server_updated:
# Parse ISO format timestamp
server_dt = datetime.datetime.fromisoformat(server_updated.replace("Z", "+00:00"))
server_dt = datetime.datetime.fromisoformat(
server_updated.replace("Z", "+00:00")
)
if server_dt > local_mtime:
conflicts.append("Server version is newer than local file.")
except Exception:
@@ -1003,7 +931,9 @@ class SiloPullDialog:
# Revision table
self._table = QtGui.QTableWidget()
self._table.setColumnCount(5)
self._table.setHorizontalHeaderLabels(["Rev", "Date", "Comment", "Status", "File"])
self._table.setHorizontalHeaderLabels(
["Rev", "Date", "Comment", "Status", "File"]
)
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
@@ -1075,6 +1005,67 @@ class SiloPullDialog:
return None
def _pull_dependencies(part_number, progress_callback=None):
"""Recursively pull all BOM children that have files on the server.
Returns list of (part_number, dest_path) tuples for successfully pulled files.
Skips children that already exist locally.
"""
pulled = []
try:
bom = _client.get_bom(part_number)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not fetch BOM for {part_number}: {e}\n")
return pulled
for entry in bom:
child_pn = entry.get("child_part_number")
if not child_pn:
continue
# Skip if already exists locally
existing = find_file_by_part_number(child_pn)
if existing and existing.exists():
FreeCAD.Console.PrintMessage(
f" {child_pn}: already exists at {existing}\n"
)
# Still recurse — this child may itself be an assembly with missing deps
_pull_dependencies(child_pn, progress_callback)
continue
# Check if this child has a file on the server
try:
latest = _client.latest_file_revision(child_pn)
except Exception:
latest = None
if not latest or not latest.get("file_key"):
FreeCAD.Console.PrintMessage(f" {child_pn}: no file on server, skipping\n")
continue
# Determine destination path
child_desc = entry.get("child_description", "")
dest_path = get_cad_file_path(child_pn, child_desc)
dest_path.parent.mkdir(parents=True, exist_ok=True)
rev_num = latest["revision_number"]
FreeCAD.Console.PrintMessage(f" Pulling {child_pn} rev {rev_num}...\n")
try:
ok = _client._download_file(
child_pn, rev_num, str(dest_path), progress_callback=progress_callback
)
if ok:
pulled.append((child_pn, dest_path))
except Exception as e:
FreeCAD.Console.PrintWarning(f" Failed to pull {child_pn}: {e}\n")
# Recurse into child (it may be a sub-assembly)
_pull_dependencies(child_pn, progress_callback)
return pulled
class Silo_Pull:
"""Download from MinIO / sync from database."""
@@ -1117,14 +1108,18 @@ class Silo_Pull:
if not has_any_file:
if existing_local:
FreeCAD.Console.PrintMessage(f"Opening existing local file: {existing_local}\n")
FreeCAD.Console.PrintMessage(
f"Opening existing local file: {existing_local}\n"
)
FreeCAD.openDocument(str(existing_local))
else:
try:
item = _client.get_item(part_number)
new_doc = _sync.create_document_for_item(item, save=True)
if new_doc:
FreeCAD.Console.PrintMessage(f"Created local file for {part_number}\n")
FreeCAD.Console.PrintMessage(
f"Created local file for {part_number}\n"
)
else:
QtGui.QMessageBox.warning(
None,
@@ -1202,6 +1197,19 @@ class Silo_Pull:
FreeCAD.Console.PrintMessage(f"Pulled revision {rev_num} of {part_number}\n")
# Pull assembly dependencies before opening so links resolve
if item.get("item_type") == "assembly":
progress.setLabelText(f"Pulling dependencies for {part_number}...")
progress.setValue(0)
progress.show()
dep_pulled = _pull_dependencies(part_number, progress_callback=on_progress)
progress.setValue(100)
progress.close()
if dep_pulled:
FreeCAD.Console.PrintMessage(
f"Pulled {len(dep_pulled)} dependency file(s)\n"
)
# Close existing document if open, then reopen
if doc and doc.FileName == str(dest_path):
FreeCAD.closeDocument(doc.Name)
@@ -1255,7 +1263,9 @@ class Silo_Push:
server_dt = datetime.fromisoformat(
server_time_str.replace("Z", "+00:00")
)
local_dt = datetime.fromtimestamp(local_mtime, tz=timezone.utc)
local_dt = datetime.fromtimestamp(
local_mtime, tz=timezone.utc
)
if local_dt > server_dt:
unuploaded.append(lf)
else:
@@ -1268,7 +1278,9 @@ class Silo_Push:
pass # Not in DB, skip
if not unuploaded:
QtGui.QMessageBox.information(None, "Push", "All local files are already uploaded.")
QtGui.QMessageBox.information(
None, "Push", "All local files are already uploaded."
)
return
msg = f"Found {len(unuploaded)} files to upload:\n\n"
@@ -1286,7 +1298,9 @@ class Silo_Push:
uploaded = 0
for item in unuploaded:
result = _sync.upload_file(item["part_number"], item["path"], "Synced from local")
result = _sync.upload_file(
item["part_number"], item["path"], "Synced from local"
)
if result:
uploaded += 1
@@ -1335,9 +1349,7 @@ class Silo_Info:
msg = f"<h3>{part_number}</h3>"
msg += f"<p><b>Type:</b> {item.get('item_type', '-')}</p>"
msg += f"<p><b>Description:</b> {item.get('description', '-')}</p>"
msg += (
f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
)
msg += f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
msg += f"<p><b>Current Revision:</b> {item.get('current_revision', 1)}</p>"
msg += f"<p><b>Local Revision:</b> {getattr(obj, 'SiloRevision', '-')}</p>"
@@ -1403,7 +1415,9 @@ class Silo_TagProjects:
try:
# Get current projects for item
current_projects = _client.get_item_projects(part_number)
current_codes = {p.get("code", "") for p in current_projects if p.get("code")}
current_codes = {
p.get("code", "") for p in current_projects if p.get("code")
}
# Get all available projects
all_projects = _client.get_projects()
@@ -1514,7 +1528,9 @@ class Silo_Rollback:
dialog.setMinimumHeight(300)
layout = QtGui.QVBoxLayout(dialog)
label = QtGui.QLabel(f"Select a revision to rollback to (current: Rev {current_rev}):")
label = QtGui.QLabel(
f"Select a revision to rollback to (current: Rev {current_rev}):"
)
layout.addWidget(label)
# Revision table
@@ -1529,8 +1545,12 @@ class Silo_Rollback:
for i, rev in enumerate(prev_revisions):
table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev["revision_number"])))
table.setItem(i, 1, QtGui.QTableWidgetItem(rev.get("status", "draft")))
table.setItem(i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10]))
table.setItem(i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or ""))
table.setItem(
i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10])
)
table.setItem(
i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or "")
)
table.resizeColumnsToContents()
layout.addWidget(table)
@@ -1556,7 +1576,9 @@ class Silo_Rollback:
def on_rollback():
selected = table.selectedItems()
if not selected:
QtGui.QMessageBox.warning(dialog, "Rollback", "Please select a revision")
QtGui.QMessageBox.warning(
dialog, "Rollback", "Please select a revision"
)
return
selected_rev[0] = int(table.item(selected[0].row(), 0).text())
dialog.accept()
@@ -1654,7 +1676,9 @@ class Silo_SetStatus:
# Update status
_client.update_revision(part_number, rev_num, status=status)
FreeCAD.Console.PrintMessage(f"Updated Rev {rev_num} status to '{status}'\n")
FreeCAD.Console.PrintMessage(
f"Updated Rev {rev_num} status to '{status}'\n"
)
QtGui.QMessageBox.information(
None, "Status Updated", f"Revision {rev_num} status set to '{status}'"
)
@@ -1718,7 +1742,9 @@ class Silo_Settings:
ssl_checkbox.setChecked(param.GetBool("SslVerify", True))
layout.addWidget(ssl_checkbox)
ssl_hint = QtGui.QLabel("Disable only for internal servers with self-signed certificates.")
ssl_hint = QtGui.QLabel(
"Disable only for internal servers with self-signed certificates."
)
ssl_hint.setWordWrap(True)
ssl_hint.setStyleSheet("color: #888; font-size: 11px;")
layout.addWidget(ssl_hint)
@@ -1995,7 +2021,9 @@ class Silo_BOM:
wu_table = QtGui.QTableWidget()
wu_table.setColumnCount(5)
wu_table.setHorizontalHeaderLabels(["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"])
wu_table.setHorizontalHeaderLabels(
["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"]
)
wu_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
wu_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
wu_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
@@ -2024,12 +2052,16 @@ class Silo_BOM:
bom_table.setItem(
row, 1, QtGui.QTableWidgetItem(entry.get("child_description", ""))
)
bom_table.setItem(row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", "")))
bom_table.setItem(
row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
)
qty = entry.get("quantity")
bom_table.setItem(
row, 3, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
)
bom_table.setItem(row, 4, QtGui.QTableWidgetItem(entry.get("unit") or ""))
bom_table.setItem(
row, 4, QtGui.QTableWidgetItem(entry.get("unit") or "")
)
ref_des = entry.get("reference_designators") or []
bom_table.setItem(row, 5, QtGui.QTableWidgetItem(", ".join(ref_des)))
bom_table.setItem(
@@ -2051,12 +2083,16 @@ class Silo_BOM:
wu_table.setItem(
row, 0, QtGui.QTableWidgetItem(entry.get("parent_part_number", ""))
)
wu_table.setItem(row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", "")))
wu_table.setItem(
row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
)
qty = entry.get("quantity")
wu_table.setItem(
row, 2, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
)
wu_table.setItem(row, 3, QtGui.QTableWidgetItem(entry.get("unit") or ""))
wu_table.setItem(
row, 3, QtGui.QTableWidgetItem(entry.get("unit") or "")
)
ref_des = entry.get("reference_designators") or []
wu_table.setItem(row, 4, QtGui.QTableWidgetItem(", ".join(ref_des)))
wu_table.resizeColumnsToContents()
@@ -2109,7 +2145,9 @@ class Silo_BOM:
try:
qty = float(qty_text)
except ValueError:
QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.")
QtGui.QMessageBox.warning(
dialog, "BOM", "Quantity must be a number."
)
return
unit = unit_input.text().strip() or None
@@ -2188,7 +2226,9 @@ class Silo_BOM:
try:
new_qty = float(qty_text)
except ValueError:
QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.")
QtGui.QMessageBox.warning(
dialog, "BOM", "Quantity must be a number."
)
return
new_unit = unit_input.text().strip() or None
@@ -2212,7 +2252,9 @@ class Silo_BOM:
)
load_bom()
except Exception as exc:
QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to update entry:\n{exc}")
QtGui.QMessageBox.warning(
dialog, "BOM", f"Failed to update entry:\n{exc}"
)
def on_remove():
selected = bom_table.selectedItems()
@@ -2238,7 +2280,9 @@ class Silo_BOM:
_client.delete_bom_entry(part_number, child_pn)
load_bom()
except Exception as exc:
QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to remove entry:\n{exc}")
QtGui.QMessageBox.warning(
dialog, "BOM", f"Failed to remove entry:\n{exc}"
)
add_btn.clicked.connect(on_add)
edit_btn.clicked.connect(on_edit)
@@ -2277,7 +2321,9 @@ class SiloEventListener(QtCore.QThread):
item_updated = QtCore.Signal(str) # part_number
revision_created = QtCore.Signal(str, int) # part_number, revision
connection_status = QtCore.Signal(str, int, str) # (status, retry_count, error_message)
connection_status = QtCore.Signal(
str, int, str
) # (status, retry_count, error_message)
server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded"
_MAX_RETRIES = 10
@@ -2351,7 +2397,9 @@ class SiloEventListener(QtCore.QThread):
req = urllib.request.Request(url, headers=headers, method="GET")
try:
self._response = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=90)
self._response = urllib.request.urlopen(
req, context=_get_ssl_context(), timeout=90
)
except urllib.error.HTTPError as e:
if e.code in (404, 501):
raise _SSEUnsupported()
@@ -2422,6 +2470,8 @@ class SiloAuthDockWidget:
self.widget = QtGui.QWidget()
self._event_listener = None
self._activity_events = [] # list of (datetime, text, part_number)
self._activity_seeded = False
self._build_ui()
self._refresh_status()
@@ -2429,6 +2479,11 @@ class SiloAuthDockWidget:
self._timer.timeout.connect(self._refresh_status)
self._timer.start(30000)
# Refresh relative timestamps every 60s
self._ts_timer = QtCore.QTimer(self.widget)
self._ts_timer.timeout.connect(self._rebuild_activity_feed)
self._ts_timer.start(60000)
# -- UI construction ----------------------------------------------------
def _build_ui(self):
@@ -2631,8 +2686,11 @@ class SiloAuthDockWidget:
self._sse_label.setStyleSheet("font-size: 11px; color: #4CAF50;")
self._sse_label.setToolTip("")
FreeCAD.Console.PrintMessage("Silo: SSE connected\n")
self._seed_activity_feed()
elif status == "disconnected":
self._sse_label.setText(f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})...")
self._sse_label.setText(
f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})..."
)
self._sse_label.setStyleSheet("font-size: 11px; color: #FF9800;")
self._sse_label.setToolTip(error or "Connection lost")
FreeCAD.Console.PrintWarning(
@@ -2642,7 +2700,9 @@ class SiloAuthDockWidget:
self._sse_label.setText("Disconnected")
self._sse_label.setStyleSheet("font-size: 11px; color: #F44336;")
self._sse_label.setToolTip(error or "Max retries reached")
FreeCAD.Console.PrintError(f"Silo: SSE gave up after {retry} retries: {error}\n")
FreeCAD.Console.PrintError(
f"Silo: SSE gave up after {retry} retries: {error}\n"
)
elif status == "unsupported":
self._sse_label.setText("Not available")
self._sse_label.setStyleSheet("font-size: 11px; color: #888;")
@@ -2651,6 +2711,8 @@ class SiloAuthDockWidget:
global _server_mode
_server_mode = mode
self._update_mode_banner()
if mode != "normal":
self._append_activity_event(f"Server mode: {mode}")
def _update_mode_banner(self):
_MODE_BANNERS = {
@@ -2681,18 +2743,59 @@ class SiloAuthDockWidget:
mw = FreeCADGui.getMainWindow()
if mw is not None:
mw.statusBar().showMessage(f"Silo: {part_number} updated on server", 5000)
self._refresh_activity_panel()
self._append_activity_event(f"{part_number} updated", part_number)
def _on_remote_revision(self, part_number, revision):
FreeCAD.Console.PrintMessage(f"Silo: New revision {revision} for {part_number}\n")
FreeCAD.Console.PrintMessage(
f"Silo: New revision {revision} for {part_number}\n"
)
mw = FreeCADGui.getMainWindow()
if mw is not None:
mw.statusBar().showMessage(f"Silo: {part_number} rev {revision} available", 5000)
self._refresh_activity_panel()
mw.statusBar().showMessage(
f"Silo: {part_number} rev {revision} available", 5000
)
self._append_activity_event(
f"{part_number} Rev {revision} created", part_number
)
def _refresh_activity_panel(self):
"""Refresh the Database Activity panel if it exists."""
from PySide import QtCore, QtGui, QtWidgets
def _append_activity_event(self, text, pn=""):
"""Prepend an event to the activity feed and rebuild the display."""
self._activity_events.insert(0, (datetime.now(), text, pn))
self._activity_events = self._activity_events[:50]
self._rebuild_activity_feed()
def _seed_activity_feed(self):
"""One-time: populate the feed with recent items from the database."""
if self._activity_seeded:
return
self._activity_seeded = True
try:
items = _client.list_items()
if isinstance(items, list):
for item in reversed(items[:10]):
pn = item.get("part_number", "")
desc = item.get("description", "")
if desc and len(desc) > 40:
desc = desc[:37] + "..."
text = f"{pn} \u2013 {desc}" if desc else pn
updated = item.get("updated_at", "")
ts = datetime.now()
if updated:
try:
ts = datetime.fromisoformat(
updated.replace("Z", "+00:00")
).replace(tzinfo=None)
except (ValueError, AttributeError):
pass
self._activity_events.insert(0, (ts, text, pn))
self._activity_events = self._activity_events[:50]
except Exception:
pass
self._rebuild_activity_feed()
def _rebuild_activity_feed(self):
"""Render _activity_events into the Database Activity QListWidget."""
from PySide import QtCore, QtWidgets
mw = FreeCADGui.getMainWindow()
if mw is None:
@@ -2714,64 +2817,18 @@ class SiloAuthDockWidget:
)
activity_list._silo_connected = True
# Collect local part numbers for badge
local_pns = set()
try:
for lf in search_local_files():
local_pns.add(lf.get("part_number", ""))
except Exception:
pass
if not self._activity_events:
item = QtWidgets.QListWidgetItem("(No activity yet)")
item.setFlags(QtCore.Qt.NoItemFlags)
activity_list.addItem(item)
return
try:
items = _client.list_items()
if isinstance(items, list):
for item in items[:20]:
pn = item.get("part_number", "")
desc = item.get("description", "")
updated = item.get("updated_at", "")
if updated:
updated = updated[:10]
# Fetch latest revision info
rev_num = ""
comment = ""
try:
revs = _client.get_revisions(pn)
if revs:
latest = revs[0] if isinstance(revs, list) else revs
rev_num = str(latest.get("revision_number", ""))
comment = latest.get("comment", "") or ""
except Exception:
pass
# Truncate long descriptions
desc_display = desc
if len(desc_display) > 40:
desc_display = desc_display[:37] + "..."
# Build display text
rev_part = f" \u2013 Rev {rev_num}" if rev_num else ""
date_part = f" \u2013 {updated}" if updated else ""
local_badge = " \u25cf local" if pn in local_pns else ""
line1 = f"{pn} \u2013 {desc_display}{rev_part}{date_part}{local_badge}"
if comment:
line1 += f'\n "{comment}"'
else:
line1 += "\n (no comment)"
list_item = QtWidgets.QListWidgetItem(line1)
list_item.setData(QtCore.Qt.UserRole, pn)
if desc and len(desc) > 40:
list_item.setToolTip(desc)
if pn in local_pns:
list_item.setForeground(QtGui.QColor("#4CAF50"))
activity_list.addItem(list_item)
if activity_list.count() == 0:
activity_list.addItem("(No items in database)")
except Exception:
activity_list.addItem("(Unable to refresh activity)")
for ts, text, pn in self._activity_events:
label = f"{text} \u00b7 {_relative_time(ts)}"
list_item = QtWidgets.QListWidgetItem(label)
if pn:
list_item.setData(QtCore.Qt.UserRole, pn)
activity_list.addItem(list_item)
def _on_activity_double_click(self, item):
"""Open/checkout item from activity pane."""
@@ -3209,7 +3266,9 @@ class Silo_StartPanel:
dock = QtGui.QDockWidget("Silo", mw)
dock.setObjectName("SiloStartPanel")
dock.setWidget(content.widget)
dock.setAllowedAreas(QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea)
dock.setAllowedAreas(
QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea
)
mw.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock)
def IsActive(self):
@@ -3243,9 +3302,9 @@ class _DiagWorker(QtCore.QThread):
self.result.emit("DNS", False, "no hostname in URL")
return
try:
addrs = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
first_ip = addrs[0][4][0] if addrs else "?"
self.result.emit("DNS", True, f"{hostname} -> {first_ip}")
addrs = socket.getaddrinfo(
hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM
)
except socket.gaierror as e:
self.result.emit("DNS", False, f"{hostname}: {e}")
except Exception as e: