Compare commits
10 Commits
fix/bom-re
...
feat/nativ
| Author | SHA1 | Date | |
|---|---|---|---|
| af7eab3a70 | |||
| 85bfb17854 | |||
| 6d231e80dd | |||
| a7ef5f195b | |||
|
|
7cf5867a7a | ||
|
|
9a6d1dfbd2 | ||
| 8937cb5e8b | |||
| a53cd52c73 | |||
|
|
c6e187a75c | ||
|
|
2e9bf52082 |
@@ -67,3 +67,31 @@ class SiloWorkbench(FreeCADGui.Workbench):
|
||||
|
||||
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)
|
||||
|
||||
@@ -26,9 +26,7 @@ from silo_client import (
|
||||
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
|
||||
|
||||
# Configuration - preferences take priority over env vars
|
||||
SILO_PROJECTS_DIR = os.environ.get(
|
||||
"SILO_PROJECTS_DIR", os.path.expanduser("~/projects")
|
||||
)
|
||||
SILO_PROJECTS_DIR = os.environ.get("SILO_PROJECTS_DIR", os.path.expanduser("~/projects"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -66,9 +64,7 @@ class FreeCADSiloSettings(SiloSettings):
|
||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||
return param.GetString("SslCertPath", "")
|
||||
|
||||
def save_auth(
|
||||
self, username: str, role: str = "", source: str = "", token: str = ""
|
||||
):
|
||||
def save_auth(self, username: str, role: str = "", source: str = "", token: str = ""):
|
||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||
param.SetString("AuthUsername", username)
|
||||
param.SetString("AuthRole", role)
|
||||
@@ -126,9 +122,7 @@ def _get_ssl_verify() -> bool:
|
||||
def _get_ssl_context():
|
||||
from silo_client._ssl import build_ssl_context
|
||||
|
||||
return build_ssl_context(
|
||||
_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path()
|
||||
)
|
||||
return build_ssl_context(_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path())
|
||||
|
||||
|
||||
def _get_auth_headers() -> Dict[str, str]:
|
||||
@@ -185,9 +179,7 @@ def _fetch_server_mode() -> str:
|
||||
# Icon helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ICON_DIR = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "resources", "icons"
|
||||
)
|
||||
_ICON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "icons")
|
||||
|
||||
|
||||
def _icon(name):
|
||||
@@ -557,6 +549,35 @@ 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
|
||||
# ============================================================================
|
||||
@@ -642,11 +663,7 @@ class Silo_Open:
|
||||
try:
|
||||
for item in search_local_files(search_term):
|
||||
existing = next(
|
||||
(
|
||||
r
|
||||
for r in results_data
|
||||
if r["part_number"] == item["part_number"]
|
||||
),
|
||||
(r for r in results_data if r["part_number"] == item["part_number"]),
|
||||
None,
|
||||
)
|
||||
if existing:
|
||||
@@ -670,12 +687,8 @@ class Silo_Open:
|
||||
|
||||
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, 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"]))
|
||||
@@ -741,13 +754,9 @@ class Silo_New:
|
||||
try:
|
||||
schema = _client.get_schema()
|
||||
categories = schema.get("segments", [])
|
||||
cat_segment = next(
|
||||
(s for s in categories if s.get("name") == "category"), None
|
||||
)
|
||||
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())
|
||||
]
|
||||
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
|
||||
)
|
||||
@@ -755,15 +764,11 @@ class Silo_New:
|
||||
return
|
||||
category = category_str.split(" - ")[0]
|
||||
else:
|
||||
category, ok = QtGui.QInputDialog.getText(
|
||||
None, "New Item", "Category code:"
|
||||
)
|
||||
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:"
|
||||
)
|
||||
category, ok = QtGui.QInputDialog.getText(None, "New Item", "Category code:")
|
||||
if not ok:
|
||||
return
|
||||
|
||||
@@ -808,9 +813,7 @@ class Silo_New:
|
||||
ok_btn.clicked.connect(dialog.accept)
|
||||
|
||||
if dialog.exec_() == QtGui.QDialog.Accepted:
|
||||
selected_projects = [
|
||||
item.text() for item in list_widget.selectedItems()
|
||||
]
|
||||
selected_projects = [item.text() for item in list_widget.selectedItems()]
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"Could not fetch projects: {e}\n")
|
||||
|
||||
@@ -917,9 +920,7 @@ class Silo_Save:
|
||||
|
||||
# Try to upload to MinIO
|
||||
try:
|
||||
result = _client._upload_file(
|
||||
part_number, str(file_path), properties, "Auto-save"
|
||||
)
|
||||
result = _client._upload_file(part_number, str(file_path), properties, "Auto-save")
|
||||
|
||||
new_rev = result["revision_number"]
|
||||
FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n")
|
||||
@@ -952,9 +953,7 @@ class Silo_Commit:
|
||||
|
||||
obj = get_tracked_object(doc)
|
||||
if not obj:
|
||||
FreeCAD.Console.PrintError(
|
||||
"No tracked object. Use 'New' to register first.\n"
|
||||
)
|
||||
FreeCAD.Console.PrintError("No tracked object. Use 'New' to register first.\n")
|
||||
return
|
||||
|
||||
part_number = obj.SiloPartNumber
|
||||
@@ -971,9 +970,7 @@ class Silo_Commit:
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
result = _client._upload_file(
|
||||
part_number, str(file_path), properties, comment
|
||||
)
|
||||
result = _client._upload_file(part_number, str(file_path), properties, comment)
|
||||
|
||||
new_rev = result["revision_number"]
|
||||
FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n")
|
||||
@@ -1020,9 +1017,7 @@ def _check_pull_conflicts(part_number, local_path, doc=None):
|
||||
server_updated = item.get("updated_at", "")
|
||||
if server_updated:
|
||||
# Parse ISO format timestamp
|
||||
server_dt = datetime.datetime.fromisoformat(
|
||||
server_updated.replace("Z", "+00:00")
|
||||
)
|
||||
server_dt = datetime.datetime.fromisoformat(server_updated.replace("Z", "+00:00"))
|
||||
if server_dt > local_mtime:
|
||||
conflicts.append("Server version is newer than local file.")
|
||||
except Exception:
|
||||
@@ -1052,9 +1047,7 @@ class SiloPullDialog:
|
||||
# Revision table
|
||||
self._table = QtGui.QTableWidget()
|
||||
self._table.setColumnCount(5)
|
||||
self._table.setHorizontalHeaderLabels(
|
||||
["Rev", "Date", "Comment", "Status", "File"]
|
||||
)
|
||||
self._table.setHorizontalHeaderLabels(["Rev", "Date", "Comment", "Status", "File"])
|
||||
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
||||
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
||||
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
||||
@@ -1168,18 +1161,14 @@ class Silo_Pull:
|
||||
|
||||
if not has_any_file:
|
||||
if existing_local:
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Opening existing local file: {existing_local}\n"
|
||||
)
|
||||
FreeCAD.Console.PrintMessage(f"Opening existing local file: {existing_local}\n")
|
||||
FreeCAD.openDocument(str(existing_local))
|
||||
else:
|
||||
try:
|
||||
item = _client.get_item(part_number)
|
||||
new_doc = _sync.create_document_for_item(item, save=True)
|
||||
if new_doc:
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Created local file for {part_number}\n"
|
||||
)
|
||||
FreeCAD.Console.PrintMessage(f"Created local file for {part_number}\n")
|
||||
else:
|
||||
QtGui.QMessageBox.warning(
|
||||
None,
|
||||
@@ -1310,9 +1299,7 @@ class Silo_Push:
|
||||
server_dt = datetime.fromisoformat(
|
||||
server_time_str.replace("Z", "+00:00")
|
||||
)
|
||||
local_dt = datetime.fromtimestamp(
|
||||
local_mtime, tz=timezone.utc
|
||||
)
|
||||
local_dt = datetime.fromtimestamp(local_mtime, tz=timezone.utc)
|
||||
if local_dt > server_dt:
|
||||
unuploaded.append(lf)
|
||||
else:
|
||||
@@ -1325,9 +1312,7 @@ class Silo_Push:
|
||||
pass # Not in DB, skip
|
||||
|
||||
if not unuploaded:
|
||||
QtGui.QMessageBox.information(
|
||||
None, "Push", "All local files are already uploaded."
|
||||
)
|
||||
QtGui.QMessageBox.information(None, "Push", "All local files are already uploaded.")
|
||||
return
|
||||
|
||||
msg = f"Found {len(unuploaded)} files to upload:\n\n"
|
||||
@@ -1345,9 +1330,7 @@ class Silo_Push:
|
||||
|
||||
uploaded = 0
|
||||
for item in unuploaded:
|
||||
result = _sync.upload_file(
|
||||
item["part_number"], item["path"], "Synced from local"
|
||||
)
|
||||
result = _sync.upload_file(item["part_number"], item["path"], "Synced from local")
|
||||
if result:
|
||||
uploaded += 1
|
||||
|
||||
@@ -1396,7 +1379,9 @@ class Silo_Info:
|
||||
msg = f"<h3>{part_number}</h3>"
|
||||
msg += f"<p><b>Type:</b> {item.get('item_type', '-')}</p>"
|
||||
msg += f"<p><b>Description:</b> {item.get('description', '-')}</p>"
|
||||
msg += f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
|
||||
msg += (
|
||||
f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
|
||||
)
|
||||
msg += f"<p><b>Current Revision:</b> {item.get('current_revision', 1)}</p>"
|
||||
msg += f"<p><b>Local Revision:</b> {getattr(obj, 'SiloRevision', '-')}</p>"
|
||||
|
||||
@@ -1462,9 +1447,7 @@ class Silo_TagProjects:
|
||||
try:
|
||||
# Get current projects for item
|
||||
current_projects = _client.get_item_projects(part_number)
|
||||
current_codes = {
|
||||
p.get("code", "") for p in current_projects if p.get("code")
|
||||
}
|
||||
current_codes = {p.get("code", "") for p in current_projects if p.get("code")}
|
||||
|
||||
# Get all available projects
|
||||
all_projects = _client.get_projects()
|
||||
@@ -1575,9 +1558,7 @@ class Silo_Rollback:
|
||||
dialog.setMinimumHeight(300)
|
||||
layout = QtGui.QVBoxLayout(dialog)
|
||||
|
||||
label = QtGui.QLabel(
|
||||
f"Select a revision to rollback to (current: Rev {current_rev}):"
|
||||
)
|
||||
label = QtGui.QLabel(f"Select a revision to rollback to (current: Rev {current_rev}):")
|
||||
layout.addWidget(label)
|
||||
|
||||
# Revision table
|
||||
@@ -1592,12 +1573,8 @@ class Silo_Rollback:
|
||||
for i, rev in enumerate(prev_revisions):
|
||||
table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev["revision_number"])))
|
||||
table.setItem(i, 1, QtGui.QTableWidgetItem(rev.get("status", "draft")))
|
||||
table.setItem(
|
||||
i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10])
|
||||
)
|
||||
table.setItem(
|
||||
i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or "")
|
||||
)
|
||||
table.setItem(i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10]))
|
||||
table.setItem(i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or ""))
|
||||
|
||||
table.resizeColumnsToContents()
|
||||
layout.addWidget(table)
|
||||
@@ -1623,9 +1600,7 @@ class Silo_Rollback:
|
||||
def on_rollback():
|
||||
selected = table.selectedItems()
|
||||
if not selected:
|
||||
QtGui.QMessageBox.warning(
|
||||
dialog, "Rollback", "Please select a revision"
|
||||
)
|
||||
QtGui.QMessageBox.warning(dialog, "Rollback", "Please select a revision")
|
||||
return
|
||||
selected_rev[0] = int(table.item(selected[0].row(), 0).text())
|
||||
dialog.accept()
|
||||
@@ -1723,9 +1698,7 @@ class Silo_SetStatus:
|
||||
# Update status
|
||||
_client.update_revision(part_number, rev_num, status=status)
|
||||
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Updated Rev {rev_num} status to '{status}'\n"
|
||||
)
|
||||
FreeCAD.Console.PrintMessage(f"Updated Rev {rev_num} status to '{status}'\n")
|
||||
QtGui.QMessageBox.information(
|
||||
None, "Status Updated", f"Revision {rev_num} status set to '{status}'"
|
||||
)
|
||||
@@ -1789,9 +1762,7 @@ class Silo_Settings:
|
||||
ssl_checkbox.setChecked(param.GetBool("SslVerify", True))
|
||||
layout.addWidget(ssl_checkbox)
|
||||
|
||||
ssl_hint = QtGui.QLabel(
|
||||
"Disable only for internal servers with self-signed certificates."
|
||||
)
|
||||
ssl_hint = QtGui.QLabel("Disable only for internal servers with self-signed certificates.")
|
||||
ssl_hint.setWordWrap(True)
|
||||
ssl_hint.setStyleSheet("color: #888; font-size: 11px;")
|
||||
layout.addWidget(ssl_hint)
|
||||
@@ -2068,9 +2039,7 @@ class Silo_BOM:
|
||||
|
||||
wu_table = QtGui.QTableWidget()
|
||||
wu_table.setColumnCount(5)
|
||||
wu_table.setHorizontalHeaderLabels(
|
||||
["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"]
|
||||
)
|
||||
wu_table.setHorizontalHeaderLabels(["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"])
|
||||
wu_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
||||
wu_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
||||
wu_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
||||
@@ -2099,16 +2068,12 @@ class Silo_BOM:
|
||||
bom_table.setItem(
|
||||
row, 1, QtGui.QTableWidgetItem(entry.get("child_description", ""))
|
||||
)
|
||||
bom_table.setItem(
|
||||
row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
|
||||
)
|
||||
bom_table.setItem(row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", "")))
|
||||
qty = entry.get("quantity")
|
||||
bom_table.setItem(
|
||||
row, 3, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
|
||||
)
|
||||
bom_table.setItem(
|
||||
row, 4, QtGui.QTableWidgetItem(entry.get("unit") or "")
|
||||
)
|
||||
bom_table.setItem(row, 4, QtGui.QTableWidgetItem(entry.get("unit") or ""))
|
||||
ref_des = entry.get("reference_designators") or []
|
||||
bom_table.setItem(row, 5, QtGui.QTableWidgetItem(", ".join(ref_des)))
|
||||
bom_table.setItem(
|
||||
@@ -2130,16 +2095,12 @@ class Silo_BOM:
|
||||
wu_table.setItem(
|
||||
row, 0, QtGui.QTableWidgetItem(entry.get("parent_part_number", ""))
|
||||
)
|
||||
wu_table.setItem(
|
||||
row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
|
||||
)
|
||||
wu_table.setItem(row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", "")))
|
||||
qty = entry.get("quantity")
|
||||
wu_table.setItem(
|
||||
row, 2, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
|
||||
)
|
||||
wu_table.setItem(
|
||||
row, 3, QtGui.QTableWidgetItem(entry.get("unit") or "")
|
||||
)
|
||||
wu_table.setItem(row, 3, QtGui.QTableWidgetItem(entry.get("unit") or ""))
|
||||
ref_des = entry.get("reference_designators") or []
|
||||
wu_table.setItem(row, 4, QtGui.QTableWidgetItem(", ".join(ref_des)))
|
||||
wu_table.resizeColumnsToContents()
|
||||
@@ -2192,9 +2153,7 @@ class Silo_BOM:
|
||||
try:
|
||||
qty = float(qty_text)
|
||||
except ValueError:
|
||||
QtGui.QMessageBox.warning(
|
||||
dialog, "BOM", "Quantity must be a number."
|
||||
)
|
||||
QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.")
|
||||
return
|
||||
|
||||
unit = unit_input.text().strip() or None
|
||||
@@ -2273,9 +2232,7 @@ class Silo_BOM:
|
||||
try:
|
||||
new_qty = float(qty_text)
|
||||
except ValueError:
|
||||
QtGui.QMessageBox.warning(
|
||||
dialog, "BOM", "Quantity must be a number."
|
||||
)
|
||||
QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.")
|
||||
return
|
||||
|
||||
new_unit = unit_input.text().strip() or None
|
||||
@@ -2299,9 +2256,7 @@ class Silo_BOM:
|
||||
)
|
||||
load_bom()
|
||||
except Exception as exc:
|
||||
QtGui.QMessageBox.warning(
|
||||
dialog, "BOM", f"Failed to update entry:\n{exc}"
|
||||
)
|
||||
QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to update entry:\n{exc}")
|
||||
|
||||
def on_remove():
|
||||
selected = bom_table.selectedItems()
|
||||
@@ -2327,9 +2282,7 @@ class Silo_BOM:
|
||||
_client.delete_bom_entry(part_number, child_pn)
|
||||
load_bom()
|
||||
except Exception as exc:
|
||||
QtGui.QMessageBox.warning(
|
||||
dialog, "BOM", f"Failed to remove entry:\n{exc}"
|
||||
)
|
||||
QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to remove entry:\n{exc}")
|
||||
|
||||
add_btn.clicked.connect(on_add)
|
||||
edit_btn.clicked.connect(on_edit)
|
||||
@@ -2368,9 +2321,7 @@ class SiloEventListener(QtCore.QThread):
|
||||
|
||||
item_updated = QtCore.Signal(str) # part_number
|
||||
revision_created = QtCore.Signal(str, int) # part_number, revision
|
||||
connection_status = QtCore.Signal(
|
||||
str, int, str
|
||||
) # (status, retry_count, error_message)
|
||||
connection_status = QtCore.Signal(str, int, str) # (status, retry_count, error_message)
|
||||
server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded"
|
||||
|
||||
_MAX_RETRIES = 10
|
||||
@@ -2431,15 +2382,13 @@ 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")
|
||||
|
||||
try:
|
||||
self._response = urllib.request.urlopen(
|
||||
req, context=_get_ssl_context(), timeout=90
|
||||
)
|
||||
self._response = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=90)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (404, 501):
|
||||
raise _SSEUnsupported()
|
||||
@@ -2720,9 +2669,7 @@ class SiloAuthDockWidget:
|
||||
self._sse_label.setToolTip("")
|
||||
FreeCAD.Console.PrintMessage("Silo: SSE connected\n")
|
||||
elif status == "disconnected":
|
||||
self._sse_label.setText(
|
||||
f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})..."
|
||||
)
|
||||
self._sse_label.setText(f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})...")
|
||||
self._sse_label.setStyleSheet("font-size: 11px; color: #FF9800;")
|
||||
self._sse_label.setToolTip(error or "Connection lost")
|
||||
FreeCAD.Console.PrintWarning(
|
||||
@@ -2732,9 +2679,7 @@ class SiloAuthDockWidget:
|
||||
self._sse_label.setText("Disconnected")
|
||||
self._sse_label.setStyleSheet("font-size: 11px; color: #F44336;")
|
||||
self._sse_label.setToolTip(error or "Max retries reached")
|
||||
FreeCAD.Console.PrintError(
|
||||
f"Silo: SSE gave up after {retry} retries: {error}\n"
|
||||
)
|
||||
FreeCAD.Console.PrintError(f"Silo: SSE gave up after {retry} retries: {error}\n")
|
||||
elif status == "unsupported":
|
||||
self._sse_label.setText("Not available")
|
||||
self._sse_label.setStyleSheet("font-size: 11px; color: #888;")
|
||||
@@ -2776,14 +2721,10 @@ class SiloAuthDockWidget:
|
||||
self._refresh_activity_panel()
|
||||
|
||||
def _on_remote_revision(self, part_number, revision):
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Silo: New revision {revision} for {part_number}\n"
|
||||
)
|
||||
FreeCAD.Console.PrintMessage(f"Silo: New revision {revision} for {part_number}\n")
|
||||
mw = FreeCADGui.getMainWindow()
|
||||
if mw is not None:
|
||||
mw.statusBar().showMessage(
|
||||
f"Silo: {part_number} rev {revision} available", 5000
|
||||
)
|
||||
mw.statusBar().showMessage(f"Silo: {part_number} rev {revision} available", 5000)
|
||||
self._refresh_activity_panel()
|
||||
|
||||
def _refresh_activity_panel(self):
|
||||
@@ -2849,9 +2790,7 @@ class SiloAuthDockWidget:
|
||||
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}"
|
||||
)
|
||||
line1 = f"{pn} \u2013 {desc_display}{rev_part}{date_part}{local_badge}"
|
||||
|
||||
if comment:
|
||||
line1 += f'\n "{comment}"'
|
||||
@@ -3307,9 +3246,7 @@ class Silo_StartPanel:
|
||||
dock = QtGui.QDockWidget("Silo", mw)
|
||||
dock.setObjectName("SiloStartPanel")
|
||||
dock.setWidget(content.widget)
|
||||
dock.setAllowedAreas(
|
||||
QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea
|
||||
)
|
||||
dock.setAllowedAreas(QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea)
|
||||
mw.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock)
|
||||
|
||||
def IsActive(self):
|
||||
@@ -3343,9 +3280,7 @@ class _DiagWorker(QtCore.QThread):
|
||||
self.result.emit("DNS", False, "no hostname in URL")
|
||||
return
|
||||
try:
|
||||
addrs = socket.getaddrinfo(
|
||||
hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM
|
||||
)
|
||||
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:
|
||||
|
||||
@@ -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,8 +392,10 @@ 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:
|
||||
@@ -473,10 +469,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 +487,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 +505,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 +520,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 +533,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
667
freecad/silo_start.py
Normal 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")
|
||||
Reference in New Issue
Block a user