Compare commits

..

30 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
972dc07157 feat(silo): replace modal new-item dialog with MDI pre-document tab
Extract SchemaFormWidget from SchemaFormDialog so the creation form
can be embedded as a plain QWidget in an MDI subwindow tab.  Each
Ctrl+N invocation opens a new tab alongside document tabs.  On
successful creation the pre-document tab closes and the real document
opens in its place.

- SchemaFormWidget emits item_created/cancelled signals
- SchemaFormDialog preserved as thin modal wrapper for backward compat
- Inline error display replaces modal QMessageBox
- Live tab title updates from part number preview
2026-02-11 15:14:38 -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
201e0af450 feat: register Silo overlay context for EditingContextResolver
Add 'Silo Origin' toolbar (Commit/Pull/Push/Info/BOM) registered with
Unavailable visibility. Register a Silo overlay via
FreeCADGui.registerEditingOverlay() that appends this toolbar to any
active editing context when the current document is Silo-tracked
(ownsDocument() returns True).

Consolidate PySide.QtCore imports.
2026-02-11 13:11:47 -06: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
95c56fa29a Merge pull request 'feat/schema-driven-new-item-form' (#18) from feat/schema-driven-new-item-form into main
Reviewed-on: #18
2026-02-11 16:19:05 +00:00
33d5eeb76c Merge branch 'main' into feat/schema-driven-new-item-form 2026-02-11 16:18:55 +00:00
9e83982c78 feat: schema-driven Qt form for new item creation
Replace the 3-dialog chain (category → description → projects) with a
single SchemaFormDialog that fetches schema data from the Silo REST API
and builds the UI dynamically at runtime.

New dialog features:
- Two-stage category picker (domain combo → subcategory combo)
- Dynamic property fields grouped by domain and common defaults
- Collapsible form sections (Identity, Sourcing, Details, Properties)
- Live part number preview via POST /api/generate-part-number
- Item type selection (part, assembly, consumable, tool)
- Sourcing fields (type, cost, URL)
- Project tagging via multi-select list
- Widget factory: string→QLineEdit, number→QDoubleSpinBox+unit,
  boolean→QCheckBox

The form mirrors the React CreateItemPane.tsx layout and uses the same
API endpoints:
- GET /api/schemas/kindred-rd (category enum values)
- GET /api/schemas/kindred-rd/properties?category={code}
- GET /api/projects
- POST /api/items (via _client.create_item)
2026-02-11 08:42:18 -06:00
e83769090b fix: use Qt enum for setWindowModality instead of raw integer
PySide6 requires the proper enum type QtCore.Qt.WindowModal, not the
raw integer 2. The integer form was accepted by PySide2/Qt5 but raises
TypeError in PySide6.
2026-02-11 07:40:05 -06:00
52fc9cdd3a Merge pull request 'fix: save Modified attribute and SSE retry reset' (#17) from fix/silo-sse-and-save into main
Reviewed-on: #17
2026-02-11 01:15:53 +00:00
ae132948d1 Merge branch 'main' into fix/silo-sse-and-save 2026-02-11 01:15:39 +00:00
Zoe Forbes
ab801601c9 fix: save Modified attribute and SSE retry reset
- silo_origin.py: use Gui.Document.Modified instead of App.Document.Modified
  (re-applies fix from #13 that was lost in rebase)
- silo_origin.py: add traceback logging to saveDocument error handler
- silo_commands.py: reset SSE retry counter after connections lasting >30s
  so transient disconnects don't permanently kill the listener
2026-02-10 19:00:13 -06:00
32d5f1ea1b Merge pull request 'fix: use FreeCADGui.Document.Modified instead of App.Document.IsModified()' (#16) from fix/pull-is-modified-bug into main
Reviewed-on: #16
2026-02-10 16:41:23 +00:00
de80e392f5 Merge branch 'main' into fix/pull-is-modified-bug 2026-02-10 16:41:15 +00:00
ba42343577 Merge pull request 'feat: native Qt start panel with Silo API + kindred:// URL scheme' (#15) from feat/native-start-panel-167 into main
Reviewed-on: #15
2026-02-10 16:40:58 +00:00
af7eab3a70 Merge branch 'main' into feat/native-start-panel-167 2026-02-10 16:40:49 +00:00
6c9789fdf3 fix: use FreeCADGui.Document.Modified instead of App.Document.IsModified()
App.Document has no IsModified() method, causing Silo_Pull to crash with
AttributeError. The correct API is to get the Gui document and check its
Modified property, consistent with the pattern used elsewhere in this file
(lines 891, 913).
2026-02-10 10:39:13 -06:00
85bfb17854 feat: native Qt start panel with Silo API + kindred:// URL scheme
Replace QWebEngineView-based start page with a rich native Qt panel that
fetches items directly from the Silo REST API. QWebEngineView is not
available on conda-forge for Qt6.

Start panel features:
- Database Items list with search (from SiloClient.list_items)
- Recent Files list from FreeCAD preferences
- Real-time Activity Feed via SSE (SiloEventListener)
- Context menu: Open in Create, Open in Browser, Copy Part Number
- Open in Browser button (QDesktopServices)
- Catppuccin Mocha dark theme styling

URL scheme support:
- handle_kindred_url() function for kindred://item/{part_number} URLs
- Startup hook in InitGui.py for cold-start URL arguments

Closes #167
2026-02-10 10:30:12 -06:00
6d231e80dd Merge pull request 'fix: use Gui.Document.Modified instead of App.Document.Modified' (#14) from fix/save-modified-attribute into main
Reviewed-on: #14
2026-02-10 12:57:29 +00:00
a7ef5f195b Merge branch 'main' into fix/save-modified-attribute 2026-02-10 12:57:16 +00:00
Zoe Forbes
7cf5867a7a fix: use Gui.Document.Modified instead of App.Document.Modified (#13)
App.Document has no 'Modified' attribute — it only exists on
Gui.Document. This caused every Silo save to fail with:
  Silo save failed: 'App.Document' object has no attribute 'Modified'

The save itself succeeded but the modified flag was never cleared,
so the document always appeared unsaved.
2026-02-09 18:40:42 -06:00
Zoe Forbes
9a6d1dfbd2 fix: use Gui.Document.Modified instead of App.Document.Modified (#13)
App.Document has no 'Modified' attribute — it only exists on
Gui.Document. This caused every Silo save to fail with:
  Silo save failed: 'App.Document' object has no attribute 'Modified'

The save itself succeeded but the modified flag was never cleared,
so the document always appeared unsaved.
2026-02-09 18:40:18 -06:00
8937cb5e8b Merge pull request 'feat(freecad): add Silo-aware start page with webview and offline fallback' (#12) from feat/silo-start-page into main 2026-02-09 17:28:34 +00:00
a53cd52c73 feat(freecad): add Silo-aware start page with webview and offline fallback
Replaces the default FreeCAD Start page with a dual-mode view:
- Online: QWebEngineView loading the Silo web app
- Offline: native Qt fallback with recent files and connectivity status

The command override is registered at InitGui.py load time, before
the C++ StartLauncher fires.
2026-02-09 11:28:16 -06:00
Zoe Forbes
c6e187a75c Merge fix/sse-url-and-origin-open 2026-02-08 22:54:37 -06:00
Zoe Forbes
2e9bf52082 fix: SSE URL double /api/ and SiloOrigin command invocation (#84)
- Fix SSE listener URL: _listen() used '/api/events' but _get_api_url()
  already returns a URL ending in '/api', producing '/api/api/events'.
  Changed to '/events' to match _test_sse().
- Replace all Command.get().Activated() calls in silo_origin.py with
  FreeCADGui.runCommand(). The C++ Gui::Command wrapper returned by
  Command.get() does not expose .Activated() to Python.
2026-02-08 22:54:28 -06:00
Zoe Forbes
383eefce9c fix(UX): offer registration when BOM opened on untracked document (#56)
Replace the dead-end warning in Silo_BOM with a question dialog that
offers to register the document via Silo_New. If the user accepts and
registration succeeds, the BOM dialog opens seamlessly.
2026-02-08 18:46:22 -06:00
6 changed files with 1862 additions and 351 deletions

View File

@@ -35,9 +35,20 @@ class SiloWorkbench(FreeCADGui.Workbench):
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not register Silo origin: {e}\n")
# Silo origin toolbar — shown as an overlay on any context when the
# active document is Silo-tracked. Registered as Unavailable so
# EditingContextResolver controls visibility via the overlay system.
self.silo_toolbar_commands = [
"Silo_Commit",
"Silo_Pull",
"Silo_Push",
"Separator",
"Silo_Info",
"Silo_BOM",
]
self.appendToolbar("Silo Origin", self.silo_toolbar_commands, "Unavailable")
# Silo menu provides admin/management commands.
# File operations (New/Open/Save) are handled by the standard File
# toolbar via the origin system -- no separate Silo toolbar needed.
self.menu_commands = [
"Silo_Info",
"Silo_BOM",
@@ -67,3 +78,67 @@ class SiloWorkbench(FreeCADGui.Workbench):
FreeCADGui.addWorkbench(SiloWorkbench())
FreeCAD.Console.PrintMessage("Silo workbench registered\n")
# ---------------------------------------------------------------------------
# Silo overlay context — adds "Silo Origin" toolbar to any active context
# when the current document is Silo-tracked.
# ---------------------------------------------------------------------------
def _register_silo_overlay():
"""Register the Silo overlay after the Silo workbench has initialised."""
def _silo_overlay_match():
"""Return True if the active document is Silo-tracked."""
try:
doc = FreeCAD.ActiveDocument
if not doc:
return False
from silo_origin import get_silo_origin
origin = get_silo_origin()
return origin.ownsDocument(doc)
except Exception:
return False
try:
FreeCADGui.registerEditingOverlay(
"silo", # overlay id
["Silo Origin"], # toolbar names to append
_silo_overlay_match, # match function
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Silo overlay registration failed: {e}\n")
from PySide import QtCore as _QtCore
_QtCore.QTimer.singleShot(2500, _register_silo_overlay)
# Override the Start page with Silo-aware version (must happen before
# the C++ StartLauncher fires at ~100ms after GUI init)
try:
import silo_start
silo_start.register()
except Exception as e:
FreeCAD.Console.PrintWarning(f"Silo Start page override failed: {e}\n")
# Handle kindred:// URLs passed as command-line arguments on cold start.
# Delayed to run after the GUI is fully initialised and the Silo addon has
# loaded its client/sync objects.
def _handle_startup_urls():
"""Process any kindred:// URLs passed as command-line arguments."""
import sys
from silo_commands import handle_kindred_url
for arg in sys.argv[1:]:
if arg.startswith("kindred://"):
handle_kindred_url(arg)
_QtCore.QTimer.singleShot(500, _handle_startup_urls)

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]))

629
freecad/schema_form.py Normal file
View File

@@ -0,0 +1,629 @@
"""Schema-driven new-item form for Kindred Create.
Fetches schema data from the Silo REST API and builds a dynamic Qt form
that mirrors the React ``CreateItemPane`` — category picker, property
fields grouped by domain, live part number preview, and project tagging.
The primary widget is :class:`SchemaFormWidget` (a plain ``QWidget``)
which can be embedded in an MDI tab, dock panel, or wrapped in the
backward-compatible :class:`SchemaFormDialog` modal.
"""
import json
import urllib.error
import urllib.parse
import urllib.request
import FreeCAD
from PySide import QtCore, QtGui, QtWidgets
# ---------------------------------------------------------------------------
# Domain labels derived from the first character of category codes.
# ---------------------------------------------------------------------------
_DOMAIN_LABELS = {
"F": "Fasteners",
"C": "Fluid Fittings",
"R": "Motion Components",
"S": "Structural",
"E": "Electrical",
"M": "Mechanical",
"T": "Tooling",
"A": "Assemblies",
"P": "Purchased",
"X": "Custom Fabricated",
}
_ITEM_TYPES = ["part", "assembly", "consumable", "tool"]
_SOURCING_TYPES = ["manufactured", "purchased"]
# ---------------------------------------------------------------------------
# Collapsible group box
# ---------------------------------------------------------------------------
class _CollapsibleGroup(QtWidgets.QGroupBox):
"""A QGroupBox that can be collapsed by clicking its title."""
def __init__(self, title: str, parent=None, collapsed=False):
super().__init__(title, parent)
self.setCheckable(True)
self.setChecked(not collapsed)
self.toggled.connect(self._on_toggled)
self._content = QtWidgets.QWidget()
self._layout = QtWidgets.QFormLayout(self._content)
self._layout.setContentsMargins(8, 4, 8, 4)
self._layout.setSpacing(6)
outer = QtWidgets.QVBoxLayout(self)
outer.setContentsMargins(0, 4, 0, 0)
outer.addWidget(self._content)
if collapsed:
self._content.hide()
def form_layout(self) -> QtWidgets.QFormLayout:
return self._layout
def _on_toggled(self, checked: bool):
self._content.setVisible(checked)
# ---------------------------------------------------------------------------
# Category picker (domain combo → subcategory combo)
# ---------------------------------------------------------------------------
class _CategoryPicker(QtWidgets.QWidget):
"""Two chained combo boxes: domain group → subcategory within that group."""
category_changed = QtCore.Signal(str) # emits full code e.g. "F01"
def __init__(self, categories: dict, parent=None):
super().__init__(parent)
self._categories = categories # {code: description, ...}
# Group by first character
self._groups = {} # {prefix: [(code, desc), ...]}
for code, desc in sorted(categories.items()):
prefix = code[0] if code else "?"
self._groups.setdefault(prefix, []).append((code, desc))
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
self._domain_combo = QtWidgets.QComboBox()
self._domain_combo.addItem("-- Select domain --", "")
for prefix in sorted(self._groups.keys()):
label = _DOMAIN_LABELS.get(prefix, prefix)
count = len(self._groups[prefix])
self._domain_combo.addItem(f"{prefix} \u2014 {label} ({count})", prefix)
self._domain_combo.currentIndexChanged.connect(self._on_domain_changed)
layout.addWidget(self._domain_combo, 1)
self._sub_combo = QtWidgets.QComboBox()
self._sub_combo.setEnabled(False)
self._sub_combo.currentIndexChanged.connect(self._on_sub_changed)
layout.addWidget(self._sub_combo, 1)
def selected_category(self) -> str:
return self._sub_combo.currentData() or ""
def _on_domain_changed(self, _index: int):
prefix = self._domain_combo.currentData()
self._sub_combo.clear()
if not prefix:
self._sub_combo.setEnabled(False)
self.category_changed.emit("")
return
self._sub_combo.setEnabled(True)
self._sub_combo.addItem("-- Select subcategory --", "")
for code, desc in self._groups.get(prefix, []):
self._sub_combo.addItem(f"{code} \u2014 {desc}", code)
def _on_sub_changed(self, _index: int):
code = self._sub_combo.currentData() or ""
self.category_changed.emit(code)
# ---------------------------------------------------------------------------
# Property field factory
# ---------------------------------------------------------------------------
def _make_field(prop_def: dict) -> QtWidgets.QWidget:
"""Create a Qt widget for a property definition.
``prop_def`` has keys: type, default, unit, description, required.
"""
ptype = prop_def.get("type", "string")
if ptype == "boolean":
cb = QtWidgets.QCheckBox()
default = prop_def.get("default")
if default is True:
cb.setChecked(True)
if prop_def.get("description"):
cb.setToolTip(prop_def["description"])
return cb
if ptype == "number":
container = QtWidgets.QWidget()
h = QtWidgets.QHBoxLayout(container)
h.setContentsMargins(0, 0, 0, 0)
h.setSpacing(4)
spin = QtWidgets.QDoubleSpinBox()
spin.setDecimals(4)
spin.setRange(-1e9, 1e9)
spin.setSpecialValueText("") # show empty when at minimum
default = prop_def.get("default")
if default is not None and default != "":
try:
spin.setValue(float(default))
except (ValueError, TypeError):
pass
else:
spin.clear()
if prop_def.get("description"):
spin.setToolTip(prop_def["description"])
h.addWidget(spin, 1)
unit = prop_def.get("unit", "")
if unit:
unit_label = QtWidgets.QLabel(unit)
unit_label.setFixedWidth(40)
h.addWidget(unit_label)
return container
# Default: string
le = QtWidgets.QLineEdit()
default = prop_def.get("default")
if default and isinstance(default, str):
le.setText(default)
if prop_def.get("description"):
le.setPlaceholderText(prop_def["description"])
le.setToolTip(prop_def["description"])
return le
def _read_field(widget: QtWidgets.QWidget, prop_def: dict):
"""Extract the value from a property widget, type-converted."""
ptype = prop_def.get("type", "string")
if ptype == "boolean":
return widget.isChecked()
if ptype == "number":
spin = widget.findChild(QtWidgets.QDoubleSpinBox)
if spin is None:
return None
text = spin.text().strip()
if not text:
return None
return spin.value()
# string
if isinstance(widget, QtWidgets.QLineEdit):
val = widget.text().strip()
return val if val else None
return None
# ---------------------------------------------------------------------------
# Embeddable form widget
# ---------------------------------------------------------------------------
class SchemaFormWidget(QtWidgets.QWidget):
"""Schema-driven new-item form widget.
A plain ``QWidget`` that can be embedded in an MDI subwindow, dock
panel, or dialog. Emits :pyqt:`item_created` on successful creation
and :pyqt:`cancelled` when the user clicks Cancel.
"""
item_created = QtCore.Signal(dict)
cancelled = QtCore.Signal()
def __init__(self, client, parent=None):
super().__init__(parent)
self._client = client
self._prop_widgets = {} # {key: (widget, prop_def)}
self._prop_groups = [] # list of _CollapsibleGroup to clear on category change
self._categories = {}
self._projects = []
self._load_schema_data()
self._build_ui()
# Part number preview debounce timer
self._pn_timer = QtCore.QTimer(self)
self._pn_timer.setSingleShot(True)
self._pn_timer.setInterval(500)
self._pn_timer.timeout.connect(self._update_pn_preview)
# -- data loading -------------------------------------------------------
def _load_schema_data(self):
"""Fetch categories and projects from the API."""
try:
schema = self._client.get_schema()
segments = schema.get("segments", [])
cat_segment = next((s for s in segments if s.get("name") == "category"), None)
if cat_segment and cat_segment.get("values"):
self._categories = cat_segment["values"]
except Exception as e:
FreeCAD.Console.PrintWarning(f"Schema form: failed to fetch schema: {e}\n")
try:
self._projects = self._client.get_projects() or []
except Exception:
self._projects = []
def _fetch_properties(self, category: str) -> dict:
"""Fetch merged property definitions for a category."""
from silo_commands import _get_api_url, _get_auth_headers, _get_ssl_context
api_url = _get_api_url().rstrip("/")
url = f"{api_url}/schemas/kindred-rd/properties?category={urllib.parse.quote(category)}"
req = urllib.request.Request(url, method="GET")
req.add_header("Accept", "application/json")
for k, v in _get_auth_headers().items():
req.add_header(k, v)
try:
resp = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=5)
data = json.loads(resp.read().decode("utf-8"))
return data.get("properties", data)
except Exception as e:
FreeCAD.Console.PrintWarning(
f"Schema form: failed to fetch properties for {category}: {e}\n"
)
return {}
def _generate_pn_preview(self, category: str) -> str:
"""Call the server to preview the next part number."""
from silo_commands import _get_api_url, _get_auth_headers, _get_ssl_context
api_url = _get_api_url().rstrip("/")
url = f"{api_url}/generate-part-number"
payload = json.dumps({"schema": "kindred-rd", "category": category}).encode()
req = urllib.request.Request(url, data=payload, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
for k, v in _get_auth_headers().items():
req.add_header(k, v)
try:
resp = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=5)
data = json.loads(resp.read().decode("utf-8"))
return data.get("part_number", "")
except Exception:
return ""
# -- UI construction ----------------------------------------------------
def _build_ui(self):
root = QtWidgets.QVBoxLayout(self)
root.setSpacing(8)
# Inline error label (hidden by default)
self._error_label = QtWidgets.QLabel()
self._error_label.setStyleSheet(
"background-color: #f38ba8; color: #1e1e2e; "
"padding: 8px; border-radius: 4px; font-weight: bold;"
)
self._error_label.setAlignment(QtCore.Qt.AlignCenter)
self._error_label.hide()
root.addWidget(self._error_label)
# Part number preview banner
self._pn_label = QtWidgets.QLabel("Part Number: \u2014")
self._pn_label.setStyleSheet("font-size: 16px; font-weight: bold; padding: 8px;")
self._pn_label.setAlignment(QtCore.Qt.AlignCenter)
root.addWidget(self._pn_label)
# Scroll area for form content
scroll = QtWidgets.QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QtWidgets.QFrame.NoFrame)
scroll_content = QtWidgets.QWidget()
self._form_layout = QtWidgets.QVBoxLayout(scroll_content)
self._form_layout.setSpacing(8)
self._form_layout.setContentsMargins(8, 4, 8, 4)
scroll.setWidget(scroll_content)
root.addWidget(scroll, 1)
# --- Identity section ---
identity = _CollapsibleGroup("Identity")
fl = identity.form_layout()
self._type_combo = QtWidgets.QComboBox()
for t in _ITEM_TYPES:
self._type_combo.addItem(t.capitalize(), t)
fl.addRow("Type:", self._type_combo)
self._desc_edit = QtWidgets.QLineEdit()
self._desc_edit.setPlaceholderText("Item description")
fl.addRow("Description:", self._desc_edit)
self._cat_picker = _CategoryPicker(self._categories)
self._cat_picker.category_changed.connect(self._on_category_changed)
fl.addRow("Category:", self._cat_picker)
self._form_layout.addWidget(identity)
# --- Sourcing section ---
sourcing = _CollapsibleGroup("Sourcing", collapsed=True)
fl = sourcing.form_layout()
self._sourcing_combo = QtWidgets.QComboBox()
for s in _SOURCING_TYPES:
self._sourcing_combo.addItem(s.capitalize(), s)
fl.addRow("Sourcing:", self._sourcing_combo)
self._cost_spin = QtWidgets.QDoubleSpinBox()
self._cost_spin.setDecimals(2)
self._cost_spin.setRange(0, 1e9)
self._cost_spin.setPrefix("$ ")
self._cost_spin.setSpecialValueText("")
fl.addRow("Standard Cost:", self._cost_spin)
self._sourcing_url = QtWidgets.QLineEdit()
self._sourcing_url.setPlaceholderText("https://...")
fl.addRow("Sourcing URL:", self._sourcing_url)
self._form_layout.addWidget(sourcing)
# --- Details section ---
details = _CollapsibleGroup("Details", collapsed=True)
fl = details.form_layout()
self._long_desc = QtWidgets.QTextEdit()
self._long_desc.setMaximumHeight(80)
self._long_desc.setPlaceholderText("Detailed description...")
fl.addRow("Long Description:", self._long_desc)
# Project selection
self._project_list = QtWidgets.QListWidget()
self._project_list.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection)
self._project_list.setMaximumHeight(100)
for proj in self._projects:
code = proj.get("code", "")
name = proj.get("name", "")
label = f"{code} \u2014 {name}" if name else code
item = QtWidgets.QListWidgetItem(label)
item.setData(QtCore.Qt.UserRole, code)
self._project_list.addItem(item)
if self._projects:
fl.addRow("Projects:", self._project_list)
self._form_layout.addWidget(details)
# --- Dynamic property groups (inserted here on category change) ---
self._prop_insert_index = self._form_layout.count()
# Spacer
self._form_layout.addStretch()
# --- Buttons ---
btn_layout = QtWidgets.QHBoxLayout()
btn_layout.addStretch()
self._cancel_btn = QtWidgets.QPushButton("Cancel")
self._cancel_btn.clicked.connect(self.cancelled.emit)
btn_layout.addWidget(self._cancel_btn)
self._create_btn = QtWidgets.QPushButton("Create")
self._create_btn.setEnabled(False)
self._create_btn.setDefault(True)
self._create_btn.clicked.connect(self._on_create)
btn_layout.addWidget(self._create_btn)
root.addLayout(btn_layout)
# -- category change ----------------------------------------------------
def _on_category_changed(self, category: str):
"""Rebuild property groups when category selection changes."""
self._create_btn.setEnabled(bool(category))
# Remove old property groups
for group in self._prop_groups:
self._form_layout.removeWidget(group)
group.deleteLater()
self._prop_groups.clear()
self._prop_widgets.clear()
if not category:
self._pn_label.setText("Part Number: \u2014")
return
# Trigger part number preview
self._pn_timer.start()
# Fetch properties
all_props = self._fetch_properties(category)
if not all_props:
return
# Separate into category-specific and common (default) properties.
# The server merges them but we can identify category-specific ones
# by checking what the schema defines for this category prefix.
prefix = category[0]
domain_label = _DOMAIN_LABELS.get(prefix, prefix)
# We fetch the raw schema to determine which keys are category-specific.
# For now, use a heuristic: keys that are NOT in the well-known defaults
# list are category-specific.
_KNOWN_DEFAULTS = {
"manufacturer",
"manufacturer_pn",
"supplier",
"supplier_pn",
"sourcing_link",
"standard_cost",
"lead_time_days",
"minimum_order_qty",
"lifecycle_status",
"rohs_compliant",
"country_of_origin",
"notes",
}
cat_specific = {}
common = {}
for key, pdef in sorted(all_props.items()):
if key in _KNOWN_DEFAULTS:
common[key] = pdef
else:
cat_specific[key] = pdef
insert_pos = self._prop_insert_index
# Category-specific properties group
if cat_specific:
group = _CollapsibleGroup(f"{domain_label} Properties")
fl = group.form_layout()
for key, pdef in cat_specific.items():
label = key.replace("_", " ").title()
widget = _make_field(pdef)
fl.addRow(f"{label}:", widget)
self._prop_widgets[key] = (widget, pdef)
self._form_layout.insertWidget(insert_pos, group)
self._prop_groups.append(group)
insert_pos += 1
# Common properties group (collapsed by default)
if common:
group = _CollapsibleGroup("Common Properties", collapsed=True)
fl = group.form_layout()
for key, pdef in common.items():
label = key.replace("_", " ").title()
widget = _make_field(pdef)
fl.addRow(f"{label}:", widget)
self._prop_widgets[key] = (widget, pdef)
self._form_layout.insertWidget(insert_pos, group)
self._prop_groups.append(group)
def _update_pn_preview(self):
"""Fetch and display the next part number preview."""
category = self._cat_picker.selected_category()
if not category:
self._pn_label.setText("Part Number: \u2014")
return
pn = self._generate_pn_preview(category)
if pn:
self._pn_label.setText(f"Part Number: {pn}")
self.setWindowTitle(f"New: {pn}")
else:
self._pn_label.setText(f"Part Number: {category}-????")
self.setWindowTitle(f"New: {category}-????")
# -- submission ---------------------------------------------------------
def _collect_form_data(self) -> dict:
"""Collect all form values into a dict suitable for create_item."""
category = self._cat_picker.selected_category()
description = self._desc_edit.text().strip()
item_type = self._type_combo.currentData()
sourcing_type = self._sourcing_combo.currentData()
cost_text = self._cost_spin.text().strip().lstrip("$ ")
standard_cost = float(cost_text) if cost_text else None
sourcing_link = self._sourcing_url.text().strip() or None
long_description = self._long_desc.toPlainText().strip() or None
# Projects
selected_projects = []
for item in self._project_list.selectedItems():
code = item.data(QtCore.Qt.UserRole)
if code:
selected_projects.append(code)
# Properties
properties = {}
for key, (widget, pdef) in self._prop_widgets.items():
val = _read_field(widget, pdef)
if val is not None and val != "":
properties[key] = val
return {
"category": category,
"description": description,
"item_type": item_type,
"sourcing_type": sourcing_type,
"standard_cost": standard_cost,
"sourcing_link": sourcing_link,
"long_description": long_description,
"projects": selected_projects if selected_projects else None,
"properties": properties if properties else None,
}
def _on_create(self):
"""Validate and submit the form."""
self._error_label.hide()
data = self._collect_form_data()
if not data["category"]:
self._error_label.setText("Category is required.")
self._error_label.show()
return
try:
result = self._client.create_item(
"kindred-rd",
data["category"],
data["description"],
projects=data["projects"],
)
result["_form_data"] = data
self.item_created.emit(result)
except Exception as e:
self._error_label.setText(f"Failed to create item: {e}")
self._error_label.show()
# ---------------------------------------------------------------------------
# Modal dialog wrapper (backward compatibility)
# ---------------------------------------------------------------------------
class SchemaFormDialog(QtWidgets.QDialog):
"""Modal dialog wrapper around :class:`SchemaFormWidget`.
Provides the same ``exec_and_create()`` API as the original
implementation for callers that still need blocking modal behavior.
"""
def __init__(self, client, parent=None):
super().__init__(parent)
self.setWindowTitle("New Item")
self.setMinimumSize(600, 500)
self.resize(680, 700)
self._result = None
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self._form = SchemaFormWidget(client, parent=self)
self._form.item_created.connect(self._on_created)
self._form.cancelled.connect(self.reject)
layout.addWidget(self._form)
@property
def _desc_edit(self):
"""Expose description field for pre-fill by callers."""
return self._form._desc_edit
def _on_created(self, result):
self._result = result
self.accept()
def exec_and_create(self):
"""Show dialog and return the creation result, or None if cancelled."""
if self.exec_() == QtWidgets.QDialog.Accepted:
return self._result
return None

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
@@ -31,6 +32,25 @@ SILO_PROJECTS_DIR = os.environ.get(
)
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")
# ---------------------------------------------------------------------------
# FreeCAD settings adapter
# ---------------------------------------------------------------------------
@@ -557,6 +577,37 @@ class SiloSync:
_sync = SiloSync()
# ---------------------------------------------------------------------------
# kindred:// URL handler
# ---------------------------------------------------------------------------
def handle_kindred_url(url: str):
"""Handle a ``kindred://`` URL by opening the referenced item.
URL format::
kindred://item/{part_number}
kindred://item/{part_number}/revision/{rev_number}
Called from C++ ``MainWindow::processMessages()`` when a ``kindred://``
URL arrives via IPC, or from ``InitGui.py`` for cold-start URL arguments.
"""
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme != "kindred":
return
# urlparse treats "kindred://item/PN-001" as netloc="item", path="/PN-001"
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"
)
_sync.open_item(part_number)
# ============================================================================
# COMMANDS
# ============================================================================
@@ -573,157 +624,44 @@ 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
class Silo_New:
"""Create new item with part number."""
"""Create new item with part number.
Opens a pre-document MDI tab containing the schema-driven creation
form. Each invocation opens a new tab so multiple items can be
prepared in parallel. On successful creation the tab closes and
the real document opens in its place.
"""
def GetResources(self):
return {
@@ -733,122 +671,61 @@ class Silo_New:
}
def Activated(self):
from PySide import QtGui
from PySide import QtGui, QtWidgets
from schema_form import SchemaFormWidget
sel = FreeCADGui.Selection.getSelection()
# Category selection
try:
schema = _client.get_schema()
categories = schema.get("segments", [])
cat_segment = next(
(s for s in categories if s.get("name") == "category"), None
)
if cat_segment and cat_segment.get("values"):
cat_list = [
f"{k} - {v}" for k, v in sorted(cat_segment["values"].items())
]
category_str, ok = QtGui.QInputDialog.getItem(
None, "New Item", "Category:", cat_list, 0, False
)
if not ok:
return
category = category_str.split(" - ")[0]
else:
category, ok = QtGui.QInputDialog.getText(
None, "New Item", "Category code:"
)
if not ok:
return
except Exception:
category, ok = QtGui.QInputDialog.getText(
None, "New Item", "Category code:"
)
if not ok:
return
# Description
default_desc = sel[0].Label if sel else ""
description, ok = QtGui.QInputDialog.getText(
None, "New Item", "Description:", text=default_desc
)
if not ok:
mw = FreeCADGui.getMainWindow()
mdi = mw.findChild(QtWidgets.QMdiArea)
if not mdi:
return
# Optional project tagging
selected_projects = []
try:
projects = _client.get_projects()
if projects:
project_codes = [p.get("code", "") for p in projects if p.get("code")]
if project_codes:
# Multi-select dialog for projects
dialog = QtGui.QDialog()
dialog.setWindowTitle("Tag with Projects (Optional)")
dialog.setMinimumWidth(300)
layout = QtGui.QVBoxLayout(dialog)
# Each invocation creates a new pre-document tab
form = SchemaFormWidget(_client)
label = QtGui.QLabel("Select projects to tag this item with:")
layout.addWidget(label)
# Pre-fill description from current selection
sel = FreeCADGui.Selection.getSelection()
if sel:
form._desc_edit.setText(sel[0].Label)
list_widget = QtGui.QListWidget()
list_widget.setSelectionMode(QtGui.QAbstractItemView.MultiSelection)
for code in project_codes:
list_widget.addItem(code)
layout.addWidget(list_widget)
# Add as MDI subwindow (appears as a tab alongside documents)
sw = mdi.addSubWindow(form)
sw.setWindowTitle("New Item")
sw.setWindowIcon(QtGui.QIcon(_icon("new")))
sw.show()
mdi.setActiveSubWindow(sw)
btn_layout = QtGui.QHBoxLayout()
skip_btn = QtGui.QPushButton("Skip")
ok_btn = QtGui.QPushButton("Tag Selected")
btn_layout.addWidget(skip_btn)
btn_layout.addWidget(ok_btn)
layout.addLayout(btn_layout)
skip_btn.clicked.connect(dialog.reject)
ok_btn.clicked.connect(dialog.accept)
if dialog.exec_() == QtGui.QDialog.Accepted:
selected_projects = [
item.text() for item in list_widget.selectedItems()
]
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not fetch projects: {e}\n")
try:
result = _client.create_item(
"kindred-rd",
category,
description,
projects=selected_projects if selected_projects else None,
)
# On creation: process result, close tab, open real document
def _on_created(result):
part_number = result["part_number"]
if sel:
# Tag selected object
obj = sel[0]
set_silo_properties(
obj,
{
"SiloItemId": result.get("id", ""),
"SiloPartNumber": part_number,
"SiloRevision": 1,
},
)
obj.Label = part_number
_sync.save_to_canonical_path(FreeCAD.ActiveDocument, force_rename=True)
else:
# Create new document
_sync.create_document_for_item(result, save=True)
try:
sel_now = FreeCADGui.Selection.getSelection()
if sel_now:
obj = sel_now[0]
set_silo_properties(
obj,
{
"SiloItemId": result.get("id", ""),
"SiloPartNumber": part_number,
"SiloRevision": 1,
},
)
obj.Label = part_number
_sync.save_to_canonical_path(
FreeCAD.ActiveDocument, force_rename=True
)
else:
_sync.create_document_for_item(result, save=True)
msg = f"Part number: {part_number}"
if selected_projects:
msg += f"\nTagged with projects: {', '.join(selected_projects)}"
FreeCAD.Console.PrintMessage(f"Created: {part_number}\n")
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to process created item: {e}\n")
FreeCAD.Console.PrintMessage(f"Created: {part_number}\n")
QtGui.QMessageBox.information(None, "Item Created", msg)
# Close the pre-document tab
sw.close()
except Exception as e:
QtGui.QMessageBox.critical(None, "Error", str(e))
form.item_created.connect(_on_created)
form.cancelled.connect(sw.close)
def IsActive(self):
return _server_mode == "normal"
@@ -993,8 +870,10 @@ def _check_pull_conflicts(part_number, local_path, doc=None):
conflicts = []
# Check for unsaved changes in an open document
if doc is not None and doc.IsModified():
conflicts.append("Document has unsaved local changes.")
if doc is not None:
gui_doc = FreeCADGui.getDocument(doc.Name) if doc.Name else None
if gui_doc and gui_doc.Modified:
conflicts.append("Document has unsaved local changes.")
# Check local revision vs server latest
if doc is not None:
@@ -1126,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."""
@@ -1224,7 +1164,7 @@ class Silo_Pull:
progress = QtGui.QProgressDialog(
f"Downloading {part_number} rev {rev_num}...", "Cancel", 0, 100
)
progress.setWindowModality(2) # Qt.WindowModal
progress.setWindowModality(QtCore.Qt.WindowModal)
progress.setMinimumDuration(0)
progress.setValue(0)
@@ -1257,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)
@@ -1998,15 +1951,18 @@ class Silo_BOM:
obj = get_tracked_object(doc)
if not obj:
FreeCAD.Console.PrintError("No tracked Silo item in active document.\n")
from PySide import QtGui as _qg
_qg.QMessageBox.warning(
reply = QtGui.QMessageBox.question(
None,
"BOM",
"This document is not registered with Silo.\nUse Silo > New to register it first.",
"This document is not registered with Silo.\n\nRegister it now?",
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
)
return
if reply != QtGui.QMessageBox.Yes:
return
FreeCADGui.runCommand("Silo_New")
obj = get_tracked_object(doc)
if not obj:
return
part_number = obj.SiloPartNumber
@@ -2394,23 +2350,30 @@ class SiloEventListener(QtCore.QThread):
# -- thread entry -------------------------------------------------------
def run(self):
import time
retries = 0
last_error = ""
while not self._stop_flag:
t0 = time.monotonic()
try:
self._listen()
# _listen returns normally only on clean EOF / stop
if self._stop_flag:
return
retries += 1
last_error = "connection closed"
except _SSEUnsupported:
self.connection_status.emit("unsupported", 0, "")
return
except Exception as exc:
retries += 1
last_error = str(exc) or "unknown error"
# Reset retries if the connection was up for a while
elapsed = time.monotonic() - t0
if elapsed > 30:
retries = 0
retries += 1
if retries > self._MAX_RETRIES:
self.connection_status.emit("gave_up", retries - 1, last_error)
return
@@ -2428,7 +2391,7 @@ class SiloEventListener(QtCore.QThread):
# -- SSE stream reader --------------------------------------------------
def _listen(self):
url = f"{_get_api_url().rstrip('/')}/api/events"
url = f"{_get_api_url().rstrip('/')}/events"
headers = {"Accept": "text/event-stream"}
headers.update(_get_auth_headers())
req = urllib.request.Request(url, headers=headers, method="GET")
@@ -2507,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()
@@ -2514,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):
@@ -2716,6 +2686,7 @@ 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})..."
@@ -2740,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 = {
@@ -2770,7 +2743,7 @@ 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(
@@ -2781,11 +2754,48 @@ class SiloAuthDockWidget:
mw.statusBar().showMessage(
f"Silo: {part_number} rev {revision} available", 5000
)
self._refresh_activity_panel()
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:
@@ -2807,66 +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."""
@@ -3343,8 +3305,6 @@ class _DiagWorker(QtCore.QThread):
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}")
except socket.gaierror as e:
self.result.emit("DNS", False, f"{hostname}: {e}")
except Exception as e:

View File

@@ -299,9 +299,7 @@ class SiloOrigin:
Created App.Document or None
"""
try:
cmd = FreeCADGui.Command.get("Silo_New")
if cmd:
cmd.Activated()
FreeCADGui.runCommand("Silo_New")
return FreeCAD.ActiveDocument
except Exception as e:
FreeCAD.Console.PrintError(f"Silo new document failed: {e}\n")
@@ -322,9 +320,7 @@ class SiloOrigin:
if not identity:
# No identity - show search dialog
try:
cmd = FreeCADGui.Command.get("Silo_Open")
if cmd:
cmd.Activated()
FreeCADGui.runCommand("Silo_Open")
return FreeCAD.ActiveDocument
except Exception as e:
FreeCAD.Console.PrintError(f"Silo open failed: {e}\n")
@@ -354,9 +350,7 @@ class SiloOrigin:
Opened App.Document or None
"""
try:
cmd = FreeCADGui.Command.get("Silo_Open")
if cmd:
cmd.Activated()
FreeCADGui.runCommand("Silo_Open")
return FreeCAD.ActiveDocument
except Exception as e:
FreeCAD.Console.PrintError(f"Silo open failed: {e}\n")
@@ -398,12 +392,17 @@ class SiloOrigin:
obj.SiloPartNumber, str(file_path), properties, comment=""
)
# Clear modified flag
doc.Modified = False
# Clear modified flag (Modified is on Gui.Document, not App.Document)
gui_doc = FreeCADGui.getDocument(doc.Name)
if gui_doc:
gui_doc.Modified = False
return True
except Exception as e:
import traceback
FreeCAD.Console.PrintError(f"Silo save failed: {e}\n")
FreeCAD.Console.PrintError(traceback.format_exc())
return False
def saveDocumentAs(self, doc, newIdentity: str) -> bool:
@@ -473,10 +472,8 @@ class SiloOrigin:
True if command was executed
"""
try:
cmd = FreeCADGui.Command.get("Silo_Commit")
if cmd:
cmd.Activated()
return True
FreeCADGui.runCommand("Silo_Commit")
return True
except Exception as e:
FreeCAD.Console.PrintError(f"Silo commit failed: {e}\n")
return False
@@ -493,10 +490,8 @@ class SiloOrigin:
True if command was executed
"""
try:
cmd = FreeCADGui.Command.get("Silo_Pull")
if cmd:
cmd.Activated()
return True
FreeCADGui.runCommand("Silo_Pull")
return True
except Exception as e:
FreeCAD.Console.PrintError(f"Silo pull failed: {e}\n")
return False
@@ -513,10 +508,8 @@ class SiloOrigin:
True if command was executed
"""
try:
cmd = FreeCADGui.Command.get("Silo_Push")
if cmd:
cmd.Activated()
return True
FreeCADGui.runCommand("Silo_Push")
return True
except Exception as e:
FreeCAD.Console.PrintError(f"Silo push failed: {e}\n")
return False
@@ -530,9 +523,7 @@ class SiloOrigin:
doc: FreeCAD App.Document
"""
try:
cmd = FreeCADGui.Command.get("Silo_Info")
if cmd:
cmd.Activated()
FreeCADGui.runCommand("Silo_Info")
except Exception as e:
FreeCAD.Console.PrintError(f"Silo info failed: {e}\n")
@@ -545,9 +536,7 @@ class SiloOrigin:
doc: FreeCAD App.Document
"""
try:
cmd = FreeCADGui.Command.get("Silo_BOM")
if cmd:
cmd.Activated()
FreeCADGui.runCommand("Silo_BOM")
except Exception as e:
FreeCAD.Console.PrintError(f"Silo BOM failed: {e}\n")

667
freecad/silo_start.py Normal file
View File

@@ -0,0 +1,667 @@
"""Silo Start Page — native Qt start view for Kindred Create.
Replaces the default Start page with a rich native panel that fetches data
from the Silo REST API, shows real-time activity via SSE, and provides quick
access to database items and recent local files.
The command override is activated by calling ``register()`` at module level
from InitGui.py, which overwrites the C++ ``Start_Start`` command.
"""
import os
from datetime import datetime
from pathlib import Path
import FreeCAD
import FreeCADGui
from PySide import QtCore, QtGui, QtWidgets
# ---------------------------------------------------------------------------
# Catppuccin Mocha palette
# ---------------------------------------------------------------------------
_MOCHA = {
"base": "#1e1e2e",
"mantle": "#181825",
"crust": "#11111b",
"surface0": "#313244",
"surface1": "#45475a",
"surface2": "#585b70",
"text": "#cdd6f4",
"subtext0": "#a6adc8",
"subtext1": "#bac2de",
"blue": "#89b4fa",
"green": "#a6e3a1",
"red": "#f38ba8",
"peach": "#fab387",
"lavender": "#b4befe",
"overlay0": "#6c7086",
}
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_silo_base_url() -> str:
"""Return the Silo web UI root URL (without /api)."""
param = FreeCAD.ParamGet(_PREF_GROUP)
url = param.GetString("ApiUrl", "")
if not url:
url = os.environ.get("SILO_API_URL", "http://localhost:8080/api")
url = url.rstrip("/")
if url.endswith("/api"):
url = url[:-4]
return url
def _get_recent_files() -> list:
"""Read recent files from FreeCAD preferences."""
group = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/RecentFiles")
count = group.GetInt("RecentFiles", 0)
files = []
for i in range(count):
path = group.GetString(f"MRU{i}", "")
if path and os.path.exists(path):
p = Path(path)
mtime = datetime.fromtimestamp(p.stat().st_mtime)
files.append({"path": str(p), "name": p.name, "modified": mtime})
return files
def _relative_time(dt: datetime) -> str:
"""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")
# ---------------------------------------------------------------------------
# Stylesheet
# ---------------------------------------------------------------------------
_STYLESHEET = f"""
SiloStartView {{
background-color: {_MOCHA["base"]};
}}
/* --- Status banner --- */
#SiloStatusBanner {{
background-color: {_MOCHA["surface0"]};
border-radius: 8px;
}}
#SiloStatusBanner QLabel {{
color: {_MOCHA["text"]};
font-size: 13px;
}}
#SiloStatusBanner QPushButton {{
background-color: {_MOCHA["blue"]};
color: {_MOCHA["crust"]};
border: none;
border-radius: 4px;
padding: 6px 12px;
font-weight: bold;
font-size: 12px;
}}
#SiloStatusBanner QPushButton:hover {{
background-color: {_MOCHA["lavender"]};
}}
/* --- Section headers --- */
.SiloSectionHeader {{
color: {_MOCHA["text"]};
font-size: 14px;
font-weight: bold;
}}
/* --- Search field --- */
#SiloSearchField {{
background-color: {_MOCHA["surface0"]};
border: 1px solid {_MOCHA["surface1"]};
border-radius: 4px;
padding: 6px 10px;
color: {_MOCHA["text"]};
font-size: 13px;
}}
#SiloSearchField:focus {{
border-color: {_MOCHA["blue"]};
}}
/* --- List widgets --- */
.SiloList {{
background-color: {_MOCHA["mantle"]};
border: 1px solid {_MOCHA["surface0"]};
border-radius: 6px;
padding: 4px;
}}
.SiloList::item {{
padding: 8px 10px;
border-bottom: 1px solid {_MOCHA["surface0"]};
color: {_MOCHA["text"]};
}}
.SiloList::item:last {{
border-bottom: none;
}}
.SiloList::item:hover {{
background-color: {_MOCHA["surface0"]};
}}
.SiloList::item:selected {{
background-color: {_MOCHA["surface1"]};
}}
/* --- Activity feed --- */
#SiloActivityFeed {{
background-color: {_MOCHA["mantle"]};
border: 1px solid {_MOCHA["surface0"]};
border-radius: 6px;
padding: 4px;
}}
#SiloActivityFeed::item {{
padding: 6px 10px;
border-bottom: 1px solid {_MOCHA["surface0"]};
color: {_MOCHA["subtext0"]};
font-size: 12px;
}}
#SiloActivityFeed::item:last {{
border-bottom: none;
}}
/* --- Footer checkbox --- */
QCheckBox {{
color: {_MOCHA["subtext0"]};
font-size: 12px;
}}
QCheckBox::indicator {{
width: 14px;
height: 14px;
}}
"""
# ---------------------------------------------------------------------------
# Main start view
# ---------------------------------------------------------------------------
class SiloStartView(QtWidgets.QWidget):
"""Native Qt start page with Silo database items, recent files, and
real-time activity feed."""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("SiloStartView")
self._silo_url = _get_silo_base_url()
self._connected = False
self._event_listener = None
self._activity_events = [] # list of (datetime, text) tuples
self._silo_imported = False
self._silo_cmds = None # lazy ref to silo_commands module
self._build_ui()
self.setStyleSheet(_STYLESHEET)
# Debounce timer for search
self._search_timer = QtCore.QTimer(self)
self._search_timer.setSingleShot(True)
self._search_timer.setInterval(300)
self._search_timer.timeout.connect(self._refresh_items)
# Periodic refresh
self._poll_timer = QtCore.QTimer(self)
self._poll_timer.setInterval(30000)
self._poll_timer.timeout.connect(self._periodic_refresh)
self._poll_timer.start()
# Initial load after event loop starts
QtCore.QTimer.singleShot(100, self._initial_load)
# -- lazy import --------------------------------------------------------
def _silo(self):
"""Lazy-import silo_commands to avoid circular import at module load."""
if not self._silo_imported:
try:
import silo_commands
self._silo_cmds = silo_commands
except Exception as e:
FreeCAD.Console.PrintWarning(f"Silo Start: cannot import silo_commands: {e}\n")
self._silo_cmds = None
self._silo_imported = True
return self._silo_cmds
# -- UI construction ----------------------------------------------------
def _build_ui(self):
root = QtWidgets.QVBoxLayout(self)
root.setContentsMargins(32, 24, 32, 16)
root.setSpacing(0)
# --- Status banner ---
banner = QtWidgets.QFrame()
banner.setObjectName("SiloStatusBanner")
banner_layout = QtWidgets.QHBoxLayout(banner)
banner_layout.setContentsMargins(16, 10, 16, 10)
self._status_icon = QtWidgets.QLabel()
self._status_icon.setFixedSize(12, 12)
banner_layout.addWidget(self._status_icon)
self._status_label = QtWidgets.QLabel("Checking Silo connection...")
self._status_label.setWordWrap(True)
banner_layout.addWidget(self._status_label, 1)
self._browser_btn = QtWidgets.QPushButton("Open in Browser")
self._browser_btn.setFixedWidth(130)
self._browser_btn.setCursor(QtCore.Qt.PointingHandCursor)
self._browser_btn.clicked.connect(self._open_in_browser)
banner_layout.addWidget(self._browser_btn)
self._retry_btn = QtWidgets.QPushButton("Retry")
self._retry_btn.setFixedWidth(70)
self._retry_btn.setCursor(QtCore.Qt.PointingHandCursor)
self._retry_btn.clicked.connect(self._initial_load)
self._retry_btn.hide()
banner_layout.addWidget(self._retry_btn)
root.addWidget(banner)
root.addSpacing(20)
# --- Main content: items (left) + recent files (right) ---
content_splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal)
content_splitter.setHandleWidth(12)
content_splitter.setStyleSheet(
f"QSplitter::handle {{ background-color: {_MOCHA['base']}; }}"
)
# Left: Database Items
left = QtWidgets.QWidget()
left_layout = QtWidgets.QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
left_layout.setSpacing(8)
items_header = QtWidgets.QLabel("Database Items")
items_header.setProperty("class", "SiloSectionHeader")
left_layout.addWidget(items_header)
self._search_field = QtWidgets.QLineEdit()
self._search_field.setObjectName("SiloSearchField")
self._search_field.setPlaceholderText("Search items...")
self._search_field.textChanged.connect(self._on_search_changed)
left_layout.addWidget(self._search_field)
self._items_list = QtWidgets.QListWidget()
self._items_list.setProperty("class", "SiloList")
self._items_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self._items_list.itemDoubleClicked.connect(self._on_item_double_clicked)
self._items_list.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self._items_list.customContextMenuRequested.connect(self._on_item_context_menu)
left_layout.addWidget(self._items_list, 1)
content_splitter.addWidget(left)
# Right: Recent Files
right = QtWidgets.QWidget()
right_layout = QtWidgets.QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(8)
recent_header = QtWidgets.QLabel("Recent Files")
recent_header.setProperty("class", "SiloSectionHeader")
right_layout.addWidget(recent_header)
self._file_list = QtWidgets.QListWidget()
self._file_list.setProperty("class", "SiloList")
self._file_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self._file_list.itemDoubleClicked.connect(self._on_file_clicked)
right_layout.addWidget(self._file_list, 1)
content_splitter.addWidget(right)
content_splitter.setStretchFactor(0, 3)
content_splitter.setStretchFactor(1, 2)
root.addWidget(content_splitter, 1)
root.addSpacing(12)
# --- Activity Feed (bottom) ---
activity_header = QtWidgets.QLabel("Activity")
activity_header.setProperty("class", "SiloSectionHeader")
root.addWidget(activity_header)
root.addSpacing(6)
self._activity_list = QtWidgets.QListWidget()
self._activity_list.setObjectName("SiloActivityFeed")
self._activity_list.setMaximumHeight(140)
self._activity_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
root.addWidget(self._activity_list)
root.addSpacing(12)
# --- Footer ---
footer = QtWidgets.QHBoxLayout()
footer.addStretch()
self._startup_cb = QtWidgets.QCheckBox("Don't show this page on startup")
start_prefs = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Start")
show = start_prefs.GetBool("ShowOnStartup", True)
self._startup_cb.setChecked(not show)
self._startup_cb.toggled.connect(self._on_startup_toggled)
footer.addWidget(self._startup_cb)
root.addLayout(footer)
# -- data loading -------------------------------------------------------
def _initial_load(self):
"""First-time data load and SSE connection."""
self._refresh_status()
self._refresh_items()
self._refresh_recent_files()
self._start_sse()
def _periodic_refresh(self):
"""Periodic refresh of items and connection status."""
self._refresh_status()
self._refresh_items()
self._refresh_recent_files()
def _refresh_status(self):
"""Update the connection status banner."""
sc = self._silo()
if sc is None:
self._set_status(False, "Silo addon not available")
return
try:
reachable, _ = sc._client.check_connection()
except Exception:
reachable = False
self._silo_url = _get_silo_base_url()
if reachable:
self._set_status(True, f"Connected to {self._silo_url}")
else:
self._set_status(False, f"Cannot reach {self._silo_url}")
def _set_status(self, connected: bool, message: str):
self._connected = connected
if connected:
self._status_icon.setStyleSheet(
f"background-color: {_MOCHA['green']}; border-radius: 6px;"
)
self._retry_btn.hide()
self._browser_btn.show()
else:
self._status_icon.setStyleSheet(
f"background-color: {_MOCHA['red']}; border-radius: 6px;"
)
self._retry_btn.show()
self._browser_btn.hide()
self._status_label.setText(message)
def _refresh_items(self):
"""Fetch items from Silo API and populate the items list."""
sc = self._silo()
if sc is None:
return
self._items_list.clear()
search = self._search_field.text().strip()
try:
if search:
items = sc._client.list_items(search=search)
else:
items = sc._client.list_items()
except Exception:
item = QtWidgets.QListWidgetItem("(Unable to fetch items)")
item.setFlags(QtCore.Qt.NoItemFlags)
self._items_list.addItem(item)
return
if not isinstance(items, list) or not items:
item = QtWidgets.QListWidgetItem("(No items found)")
item.setFlags(QtCore.Qt.NoItemFlags)
self._items_list.addItem(item)
return
# Collect local part numbers for badge
local_pns = set()
try:
for lf in sc.search_local_files():
local_pns.add(lf.get("part_number", ""))
except Exception:
pass
for entry in items[:30]:
pn = entry.get("part_number", "")
desc = entry.get("description", "")
rev = entry.get("current_revision", "")
desc_display = desc
if len(desc_display) > 50:
desc_display = desc_display[:47] + "..."
local_badge = " [local]" if pn in local_pns else ""
rev_part = f" Rev {rev}" if rev else ""
label = f"{pn} \u2014 {desc_display}{rev_part}{local_badge}"
list_item = QtWidgets.QListWidgetItem(label)
list_item.setData(QtCore.Qt.UserRole, pn)
if desc and len(desc) > 50:
list_item.setToolTip(desc)
if pn in local_pns:
list_item.setForeground(QtGui.QColor(_MOCHA["green"]))
self._items_list.addItem(list_item)
def _refresh_recent_files(self):
"""Populate the recent files list."""
self._file_list.clear()
files = _get_recent_files()
if not files:
item = QtWidgets.QListWidgetItem("(No recent files)")
item.setFlags(QtCore.Qt.NoItemFlags)
self._file_list.addItem(item)
return
for f in files:
label = f"{f['name']}\n{_relative_time(f['modified'])}"
item = QtWidgets.QListWidgetItem(label)
item.setData(QtCore.Qt.UserRole, f["path"])
item.setToolTip(f["path"])
self._file_list.addItem(item)
# -- SSE ----------------------------------------------------------------
def _start_sse(self):
"""Connect to SSE for live activity updates."""
sc = self._silo()
if sc is None:
return
if self._event_listener is not None:
return # already running
try:
self._event_listener = sc.SiloEventListener()
self._event_listener.item_updated.connect(self._on_sse_item_updated)
self._event_listener.revision_created.connect(self._on_sse_revision_created)
self._event_listener.connection_status.connect(self._on_sse_status)
self._event_listener.start()
except Exception as e:
FreeCAD.Console.PrintLog(f"Silo Start: SSE listener failed to start: {e}\n")
def _stop_sse(self):
"""Stop SSE listener when the view is destroyed."""
if self._event_listener is not None:
self._event_listener.stop()
self._event_listener = None
def _on_sse_item_updated(self, part_number: str):
self._add_activity_event(f"{part_number} updated")
self._refresh_items()
def _on_sse_revision_created(self, part_number: str, revision: int):
self._add_activity_event(f"{part_number} Rev {revision} created")
self._refresh_items()
def _on_sse_status(self, status: str, retry: int, error: str):
if status == "connected":
FreeCAD.Console.PrintLog("Silo Start: SSE connected\n")
elif status == "disconnected":
FreeCAD.Console.PrintLog(f"Silo Start: SSE disconnected (retry {retry}): {error}\n")
def _add_activity_event(self, text: str):
"""Add an event to the activity feed."""
now = datetime.now()
self._activity_events.insert(0, (now, text))
self._activity_events = self._activity_events[:20]
self._rebuild_activity_feed()
def _rebuild_activity_feed(self):
"""Rebuild the activity list widget from stored events."""
self._activity_list.clear()
if not self._activity_events:
item = QtWidgets.QListWidgetItem("(No recent activity)")
item.setFlags(QtCore.Qt.NoItemFlags)
self._activity_list.addItem(item)
return
for ts, text in self._activity_events:
label = f"{text} \u00b7 {_relative_time(ts)}"
self._activity_list.addItem(label)
# -- interaction --------------------------------------------------------
def _on_search_changed(self, _text: str):
"""Debounce search input."""
self._search_timer.start()
def _on_item_double_clicked(self, item: QtWidgets.QListWidgetItem):
pn = item.data(QtCore.Qt.UserRole)
if not pn:
return
sc = self._silo()
if sc is None:
return
local_path = sc.find_file_by_part_number(pn)
if local_path and local_path.exists():
FreeCAD.openDocument(str(local_path))
else:
sc._sync.open_item(pn)
def _on_item_context_menu(self, pos):
item = self._items_list.itemAt(pos)
if item is None:
return
pn = item.data(QtCore.Qt.UserRole)
if not pn:
return
menu = QtWidgets.QMenu()
open_action = menu.addAction("Open in Create")
browser_action = menu.addAction("Open in Browser")
copy_action = menu.addAction("Copy Part Number")
action = menu.exec_(self._items_list.mapToGlobal(pos))
sc = self._silo()
if action == open_action:
if sc:
local_path = sc.find_file_by_part_number(pn)
if local_path and local_path.exists():
FreeCAD.openDocument(str(local_path))
else:
sc._sync.open_item(pn)
elif action == browser_action:
url = f"{_get_silo_base_url()}/items/{pn}"
QtGui.QDesktopServices.openUrl(QtCore.QUrl(url))
elif action == copy_action:
QtWidgets.QApplication.clipboard().setText(pn)
def _on_file_clicked(self, item: QtWidgets.QListWidgetItem):
path = item.data(QtCore.Qt.UserRole)
if path:
try:
FreeCADGui.open(path)
except Exception as e:
FreeCAD.Console.PrintError(f"Silo Start: failed to open {path}: {e}\n")
def _open_in_browser(self):
"""Open Silo web UI in the system browser."""
url = _get_silo_base_url()
QtGui.QDesktopServices.openUrl(QtCore.QUrl(url))
@staticmethod
def _on_startup_toggled(checked: bool):
prefs = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Start")
prefs.SetBool("ShowOnStartup", not checked)
# -- cleanup ------------------------------------------------------------
def closeEvent(self, event):
self._stop_sse()
self._poll_timer.stop()
super().closeEvent(event)
# ---------------------------------------------------------------------------
# Command override
# ---------------------------------------------------------------------------
class _SiloStartCommand:
"""Replacement for the C++ Start_Start command."""
def Activated(self):
mw = FreeCADGui.getMainWindow()
mdi = mw.findChild(QtWidgets.QMdiArea)
if not mdi:
return
# Reuse existing view if open
for sw in mdi.subWindowList():
if sw.widget() and sw.widget().objectName() == "SiloStartView":
mdi.setActiveSubWindow(sw)
sw.show()
return
# Create new view as MDI subwindow
view = SiloStartView()
sw = mdi.addSubWindow(view)
sw.setWindowTitle("Start")
sw.setWindowIcon(QtGui.QIcon(":/icons/StartCommandIcon.svg"))
sw.show()
mdi.setActiveSubWindow(sw)
def GetResources(self):
return {
"MenuText": "&Start Page",
"ToolTip": "Displays the start page",
"Pixmap": "StartCommandIcon",
}
def IsActive(self):
return True
def register():
"""Override the Start_Start command with the Silo start page.
Call this from InitGui.py at module level so the override is in
place before the C++ StartLauncher fires (100ms after GUI init).
"""
try:
FreeCADGui.addCommand("Start_Start", _SiloStartCommand())
FreeCAD.Console.PrintMessage("Silo Start: registered start page override\n")
except Exception as e:
FreeCAD.Console.PrintWarning(f"Silo Start: failed to register override: {e}\n")