Compare commits

..

39 Commits

Author SHA1 Message Date
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
Zoe Forbes
1676b3e1a0 art: add missing icons for TagProjects, Rollback, SetStatus (#60)
Create silo-tag.svg, silo-rollback.svg, and silo-status.svg in the
Catppuccin Mocha style matching existing silo icons. These were
referenced by _icon() but did not exist, causing the commands to
render without toolbar icons.
2026-02-08 18:36:22 -06:00
Zoe Forbes
06cd30e88d fix: update silo-client — consistent error handling in delete_bom_entry (#59) 2026-02-08 18:29:31 -06:00
f9924d35f7 Merge pull request 'feat: enhance Database Activity pane with comments, interaction, and badges' (#11) from feature/activity-pane-enhancements into main
Reviewed-on: #11
2026-02-08 22:23:50 +00:00
373f3aaa79 Merge branch 'main' into feature/activity-pane-enhancements 2026-02-08 22:23:40 +00:00
66b2baf510 Merge pull request 'fix: SSE reconnect with exponential backoff and terminal state' (#7) from fix/sse-reconnect into main
Reviewed-on: #7
2026-02-08 22:23:23 +00:00
35f33f2079 Merge branch 'main' into fix/sse-reconnect 2026-02-08 22:23:16 +00:00
Zoe Forbes
d26bb6da2d feat: enhance Database Activity pane with comments, interaction, and badges
Enhance the Database Activity panel in SiloAuthDockWidget:

- Show latest revision number and comment inline per activity entry
- Truncate long descriptions with ellipsis, full text in tooltip
- Mark locally checked-out items with green color and "local" badge
- Double-click opens local file or triggers checkout from server
- Right-click context menu: Open in Create, Open in Browser,
  Copy Part Number, View Revisions

Closes #9
2026-02-08 16:23:06 -06:00
Zoe Forbes
e5126c913d fix: SSE reconnect with exponential backoff and terminal state
Replace the infinite fixed-delay retry loop with exponential backoff
(1s, 2s, 4s, ... capped at 60s) and a max retry limit of 10.

Changes to SiloEventListener:
- Expand connection_status signal to (status, retry_count, error_message)
- Add exponential backoff: min(BASE_DELAY * 2^retries, MAX_DELAY)
- Add terminal "gave_up" state after MAX_RETRIES exhausted
- Capture and forward error messages from failed connection attempts

Changes to SiloAuthDockWidget._on_sse_status:
- Show retry count: "Reconnecting (3/10)..."
- Show "Disconnected" (red) on gave_up state
- Log each attempt to FreeCAD console (PrintWarning/PrintError)
- Set tooltip with last error message on the status label

Closes #2
2026-02-08 16:22:55 -06:00
76973aaae0 Merge pull request 'feat: add Silo_Diag connection diagnostics command' (#6) from feature/silo-diag into main
Reviewed-on: #6
2026-02-08 22:22:51 +00:00
Zoe Forbes
4ddbf26af7 feat: add Silo_Diag connection diagnostics command
Add a diagnostics command that sequentially tests:
- DNS resolution of the server hostname
- Health endpoint (/health)
- Readiness endpoint (/ready)
- Auth endpoint (/auth/me) with token
- SSE event stream (/events)

Results are printed to the FreeCAD console with PASS/FAIL status.

Closes #3
2026-02-08 16:22:25 -06:00
516116ae9d Merge pull request 'feat: add silo-aware start panel' (#10) from feature/start-panel into main
Reviewed-on: #10
2026-02-08 22:11:55 +00:00
Zoe Forbes
6fa60af5e0 feat: add silo-aware start panel
Add a SiloStartPanel dock widget that shows connection status, local
checkouts, recent silo activity, and local recent files. Automatically
shown when the Silo workbench is activated.

Sections:
- Connection status badge (green/gray dot with hostname)
- My Checkouts: scans local projects dir for .FCStd files, shows part
  number and description, double-click to open
- Recent Silo Activity: fetches from server, badges items present
  locally, double-click to open or checkout
- Local Recent Files: reads FreeCAD MRU list, double-click to open

Auto-refreshes every 60 seconds. Panel is reused if already open.

Closes #5
2026-02-08 16:11:33 -06:00
fd3c97ff97 Merge pull request 'feat: reflect server mode in client UI' (#8) from feature/server-mode-ui into main
Reviewed-on: #8
2026-02-08 22:10:22 +00:00
fb9e1d3188 Merge branch 'main' into feature/server-mode-ui 2026-02-08 22:10:14 +00:00
Zoe Forbes
026ed0cb8a feat: reflect server mode in client UI
Add server mode awareness to the FreeCAD client. The mode (normal,
read-only, degraded, offline) is fetched from the /ready endpoint on
each status refresh and updated in real-time via SSE server.state events.

Changes:
- Add _server_mode global and _fetch_server_mode() helper that queries
  /ready and maps response status to mode string
- Add server_mode_changed signal to SiloEventListener, handle
  server.state SSE events in _dispatch()
- Add mode banner to SiloAuthDockWidget: colored bar shown when server
  is not in normal mode (yellow=read-only, orange=degraded, red=offline)
- Update _refresh_status() to fetch mode and update banner
- Disable write commands (New, Save, Commit, Push) when server is not
  in normal mode via IsActive() checks

Closes #4
2026-02-08 16:06:48 -06:00
Zoe Forbes
45e803402d fix: use absolute import in silo_origin.py
The freecad/ directory is not a Python package (no __init__.py) — it is
added directly to sys.path by FreeCAD. The relative import
'from .silo_commands import ...' fails when silo_origin is imported as a
top-level module, causing Silo origin registration to silently fail.

Change to absolute import 'from silo_commands import ...' to match
the import style used everywhere else in the directory.
2026-02-08 10:35:54 -06:00
Zoe Forbes
fcb0a214e2 fix(gui): remove Silo toolbar and ToggleMode in favor of unified origin system
Remove the separate Silo workbench toolbar and Silo_ToggleMode command.
File operations (New/Open/Save) are now handled by the standard File
toolbar via the origin system. The Silo menu retains admin commands
(Settings, Auth, Info, BOM, TagProjects, SetStatus, Rollback).

Closes #65
2026-02-07 21:28:55 -06:00
3228ef5f79 Merge pull request 'fix(silo): fix auth crashes, menu redundancy, and origin connect' (#1) from fix/silo-workbench-bugs into main
Reviewed-on: #1
2026-02-07 20:33:25 +00:00
9 changed files with 2027 additions and 389 deletions

View File

@@ -35,19 +35,9 @@ class SiloWorkbench(FreeCADGui.Workbench):
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not register Silo origin: {e}\n")
self.toolbar_commands = [
"Silo_ToggleMode",
"Separator",
"Silo_Open",
"Silo_New",
"Silo_Save",
"Silo_Commit",
"Silo_Pull",
"Silo_Push",
]
# Menu has management/admin commands (file commands are in File menu
# via the Create module's SiloMenuManipulator)
# 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",
@@ -57,15 +47,16 @@ class SiloWorkbench(FreeCADGui.Workbench):
"Separator",
"Silo_Settings",
"Silo_Auth",
"Silo_StartPanel",
"Silo_Diag",
]
self.appendToolbar("Silo", self.toolbar_commands)
self.appendMenu("Silo", self.menu_commands)
def Activated(self):
"""Called when workbench is activated."""
FreeCAD.Console.PrintMessage("Kindred Silo workbench activated\n")
self._show_shortcut_recommendations()
FreeCADGui.runCommand("Silo_StartPanel", 0)
def Deactivated(self):
pass
@@ -73,41 +64,34 @@ class SiloWorkbench(FreeCADGui.Workbench):
def GetClassName(self):
return "Gui::PythonWorkbench"
def _show_shortcut_recommendations(self):
"""Show keyboard shortcut recommendations dialog on first activation."""
try:
param_group = FreeCAD.ParamGet(
"User parameter:BaseApp/Preferences/Mod/KindredSilo"
)
if param_group.GetBool("ShortcutsShown", False):
return
param_group.SetBool("ShortcutsShown", True)
from PySide import QtGui
msg = """<h3>Welcome to Kindred Silo!</h3>
<p>For the best experience, set up these keyboard shortcuts:</p>
<table style="margin: 10px 0;">
<tr><td><b>Ctrl+O</b></td><td> - </td><td>Silo_Open (Search & Open)</td></tr>
<tr><td><b>Ctrl+N</b></td><td> - </td><td>Silo_New (Register new item)</td></tr>
<tr><td><b>Ctrl+S</b></td><td> - </td><td>Silo_Save (Save & upload)</td></tr>
<tr><td><b>Ctrl+Shift+S</b></td><td> - </td><td>Silo_Commit (Save with comment)</td></tr>
</table>
<p><b>To set shortcuts:</b> Tools > Customize > Keyboard</p>
<p style="color: #888;">This message appears once.</p>"""
dialog = QtGui.QMessageBox()
dialog.setWindowTitle("Silo Keyboard Shortcuts")
dialog.setTextFormat(QtGui.Qt.RichText)
dialog.setText(msg)
dialog.setIcon(QtGui.QMessageBox.Information)
dialog.addButton("Set Up Now", QtGui.QMessageBox.AcceptRole)
dialog.addButton("Later", QtGui.QMessageBox.RejectRole)
if dialog.exec_() == 0:
FreeCADGui.runCommand("Std_DlgCustomize", 0)
except Exception as e:
FreeCAD.Console.PrintWarning("Silo shortcuts dialog: " + str(e) + "\n")
FreeCADGui.addWorkbench(SiloWorkbench())
FreeCAD.Console.PrintMessage("Silo workbench registered\n")
# 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)
from PySide import QtCore
QtCore.QTimer.singleShot(500, _handle_startup_urls)

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Counter-clockwise arrow -->
<polyline points="1 4 1 10 7 10" stroke="#f38ba8"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" stroke="#cba6f7"/>
<!-- Clock hands -->
<line x1="12" y1="7" x2="12" y2="12" stroke="#89dceb" stroke-width="1.5"/>
<line x1="12" y1="12" x2="15" y2="14" stroke="#89dceb" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 493 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Shield shape -->
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" fill="#313244"/>
<!-- Status bars -->
<line x1="8" y1="10" x2="16" y2="10" stroke="#a6e3a1" stroke-width="1.5"/>
<line x1="8" y1="14" x2="13" y2="14" stroke="#89dceb" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Tag shape -->
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" fill="#313244"/>
<!-- Tag hole -->
<circle cx="7" cy="7" r="1.5" fill="#cba6f7" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 373 B

576
freecad/schema_form.py Normal file
View File

@@ -0,0 +1,576 @@
"""Schema-driven new-item dialog 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.
"""
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
# ---------------------------------------------------------------------------
# Main form dialog
# ---------------------------------------------------------------------------
class SchemaFormDialog(QtWidgets.QDialog):
"""Schema-driven new-item dialog.
Fetches schema and property data from the Silo API, builds the form
dynamically, and returns the creation result on accept.
"""
def __init__(self, client, parent=None):
super().__init__(parent)
self._client = client
self._result = None
self._prop_widgets = {} # {key: (widget, prop_def)}
self._prop_groups = [] # list of _CollapsibleGroup to clear on category change
self._categories = {}
self._projects = []
self.setWindowTitle("New Item")
self.setMinimumSize(600, 500)
self.resize(680, 700)
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)
# Part number preview banner
self._pn_label = QtWidgets.QLabel("Part Number: —")
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.reject)
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}")
else:
self._pn_label.setText(f"Part Number: {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."""
data = self._collect_form_data()
if not data["category"]:
QtWidgets.QMessageBox.warning(self, "Validation", "Category is required.")
return
try:
result = self._client.create_item(
"kindred-rd",
data["category"],
data["description"],
projects=data["projects"],
)
self._result = result
self._result["_form_data"] = data
self.accept()
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Error", f"Failed to create item:\n{e}")
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

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,7 @@ providing the standardized origin interface.
import FreeCAD
import FreeCADGui
from .silo_commands import (
from silo_commands import (
_client,
_sync,
collect_document_properties,
@@ -300,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")
@@ -323,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")
@@ -355,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")
@@ -399,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:
@@ -474,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
@@ -494,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
@@ -514,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
@@ -531,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")
@@ -546,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")