Compare commits

...

5 Commits

Author SHA1 Message Date
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
3 changed files with 94 additions and 136 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",
@@ -59,13 +49,11 @@ class SiloWorkbench(FreeCADGui.Workbench):
"Silo_Auth",
]
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()
def Deactivated(self):
pass
@@ -73,41 +61,6 @@ 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")

View File

@@ -147,6 +147,39 @@ def _clear_auth():
_fc_settings.clear_auth()
# ---------------------------------------------------------------------------
# Server mode tracking
# ---------------------------------------------------------------------------
_server_mode = "offline" # "normal" | "read-only" | "degraded" | "offline"
def _fetch_server_mode() -> str:
"""Fetch server mode from the /ready endpoint.
Returns one of: "normal", "read-only", "degraded", "offline".
"""
api_url = _get_api_url().rstrip("/")
base_url = api_url[:-4] if api_url.endswith("/api") else api_url
url = f"{base_url}/ready"
try:
req = urllib.request.Request(url, method="GET")
resp = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=10)
body = resp.read(4096).decode("utf-8", errors="replace")
data = json.loads(body)
status = data.get("status", "")
if status in ("ok", "ready"):
return "normal"
if status in ("read-only", "read_only", "readonly"):
return "read-only"
if status in ("degraded",):
return "degraded"
# Unknown status but server responded — treat as normal
return "normal"
except Exception:
return "offline"
# ---------------------------------------------------------------------------
# Icon helper
# ---------------------------------------------------------------------------
@@ -817,7 +850,7 @@ class Silo_New:
QtGui.QMessageBox.critical(None, "Error", str(e))
def IsActive(self):
return True
return _server_mode == "normal"
class Silo_Save:
@@ -895,7 +928,7 @@ class Silo_Save:
FreeCAD.Console.PrintMessage("File saved locally but not uploaded.\n")
def IsActive(self):
return FreeCAD.ActiveDocument is not None
return FreeCAD.ActiveDocument is not None and _server_mode == "normal"
class Silo_Commit:
@@ -948,7 +981,7 @@ class Silo_Commit:
FreeCAD.Console.PrintError(f"Commit failed: {e}\n")
def IsActive(self):
return FreeCAD.ActiveDocument is not None
return FreeCAD.ActiveDocument is not None and _server_mode == "normal"
def _check_pull_conflicts(part_number, local_path, doc=None):
@@ -1320,7 +1353,7 @@ class Silo_Push:
QtGui.QMessageBox.information(None, "Push", f"Uploaded {uploaded} files.")
def IsActive(self):
return True
return _server_mode == "normal"
class Silo_Info:
@@ -2316,85 +2349,6 @@ class Silo_BOM:
return FreeCAD.ActiveDocument is not None
# ---------------------------------------------------------------------------
# Silo Mode toggle - swap Ctrl+O/S/N between standard and Silo commands
# ---------------------------------------------------------------------------
# Stored original shortcuts so they can be restored on toggle-off
_original_shortcuts: Dict[str, Any] = {}
def _swap_shortcuts(mapping, enable_silo):
"""Swap keyboard shortcuts between standard and Silo commands.
mapping: list of (std_cmd, silo_cmd, shortcut) tuples
enable_silo: True to assign shortcuts to Silo commands, False to restore.
"""
from PySide import QtGui
mw = FreeCADGui.getMainWindow()
if mw is None:
return
for std_cmd, silo_cmd, shortcut in mapping:
if enable_silo:
# Save and clear the standard command's shortcut
std_action = mw.findChild(QtGui.QAction, std_cmd)
if std_action:
_original_shortcuts[std_cmd] = std_action.shortcut().toString()
std_action.setShortcut("")
# Assign the shortcut to the Silo command
silo_action = mw.findChild(QtGui.QAction, silo_cmd)
if silo_action:
silo_action.setShortcut(shortcut)
else:
# Clear the Silo command's shortcut
silo_action = mw.findChild(QtGui.QAction, silo_cmd)
if silo_action:
silo_action.setShortcut("")
# Restore the standard command's original shortcut
std_action = mw.findChild(QtGui.QAction, std_cmd)
if std_action and std_cmd in _original_shortcuts:
std_action.setShortcut(_original_shortcuts.pop(std_cmd))
_SHORTCUT_MAP = [
("Std_Open", "Silo_Open", "Ctrl+O"),
("Std_Save", "Silo_Save", "Ctrl+S"),
("Std_New", "Silo_New", "Ctrl+N"),
]
class Silo_ToggleMode:
"""Toggle between standard file operations and Silo equivalents."""
def GetResources(self):
return {
"MenuText": "Silo Mode",
"ToolTip": (
"Toggle between standard file operations and Silo equivalents.\n"
"When ON: Ctrl+O/S/N use Silo Open/Save/New.\n"
"When OFF: Standard FreeCAD file operations."
),
"Pixmap": _icon("silo"),
"Checkable": True,
}
def Activated(self, checked):
param = FreeCAD.ParamGet(_PREF_GROUP)
if checked:
_swap_shortcuts(_SHORTCUT_MAP, enable_silo=True)
param.SetBool("SiloMode", True)
FreeCAD.Console.PrintMessage("Silo mode enabled\n")
else:
_swap_shortcuts(_SHORTCUT_MAP, enable_silo=False)
param.SetBool("SiloMode", False)
FreeCAD.Console.PrintMessage("Silo mode disabled\n")
def IsActive(self):
return True
# ---------------------------------------------------------------------------
# SSE live-update listener
# ---------------------------------------------------------------------------
@@ -2413,6 +2367,7 @@ class SiloEventListener(QtCore.QThread):
connection_status = QtCore.Signal(
str
) # "connected" / "disconnected" / "unsupported"
server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded"
_MAX_FAST_RETRIES = 3
_FAST_RETRY_SECS = 5
@@ -2513,6 +2468,12 @@ class SiloEventListener(QtCore.QThread):
except (json.JSONDecodeError, ValueError):
return
if event_type == "server.state":
mode = payload.get("mode", "")
if mode:
self.server_mode_changed.emit(mode)
return
pn = payload.get("part_number", "")
if not pn:
return
@@ -2620,6 +2581,13 @@ class SiloAuthDockWidget:
sse_row.addStretch()
layout.addLayout(sse_row)
# Server mode banner (hidden when normal)
self._mode_banner = QtGui.QLabel("")
self._mode_banner.setWordWrap(True)
self._mode_banner.setContentsMargins(6, 4, 6, 4)
self._mode_banner.setVisible(False)
layout.addWidget(self._mode_banner)
layout.addSpacing(4)
# Buttons
@@ -2709,6 +2677,14 @@ class SiloAuthDockWidget:
self._login_btn.setText("Login")
self._login_btn.clicked.connect(self._on_login_clicked)
# Fetch and display server mode
global _server_mode
if reachable:
_server_mode = _fetch_server_mode()
else:
_server_mode = "offline"
self._update_mode_banner()
# Manage SSE listener based on auth state
self._sync_event_listener(authed)
@@ -2722,6 +2698,7 @@ class SiloAuthDockWidget:
self._event_listener.item_updated.connect(self._on_remote_change)
self._event_listener.revision_created.connect(self._on_remote_revision)
self._event_listener.connection_status.connect(self._on_sse_status)
self._event_listener.server_mode_changed.connect(self._on_server_mode)
self._event_listener.start()
else:
if self._event_listener is not None and self._event_listener.isRunning():
@@ -2739,6 +2716,35 @@ class SiloAuthDockWidget:
self._sse_label.setText("Not available")
self._sse_label.setStyleSheet("font-size: 11px; color: #888;")
def _on_server_mode(self, mode):
global _server_mode
_server_mode = mode
self._update_mode_banner()
def _update_mode_banner(self):
_MODE_BANNERS = {
"normal": ("", "", False),
"read-only": (
"Server is in read-only mode",
"background: #FFC107; color: #000; padding: 4px; font-size: 11px;",
True,
),
"degraded": (
"MinIO unavailable \u2014 file ops limited",
"background: #FF9800; color: #000; padding: 4px; font-size: 11px;",
True,
),
"offline": (
"Disconnected from silo",
"background: #F44336; color: #fff; padding: 4px; font-size: 11px;",
True,
),
}
text, style, visible = _MODE_BANNERS.get(_server_mode, _MODE_BANNERS["offline"])
self._mode_banner.setText(text)
self._mode_banner.setStyleSheet(style)
self._mode_banner.setVisible(visible)
def _on_remote_change(self, part_number):
FreeCAD.Console.PrintMessage(f"Silo: Part {part_number} updated on server\n")
mw = FreeCADGui.getMainWindow()
@@ -2947,5 +2953,5 @@ FreeCADGui.addCommand("Silo_TagProjects", Silo_TagProjects())
FreeCADGui.addCommand("Silo_Rollback", Silo_Rollback())
FreeCADGui.addCommand("Silo_SetStatus", Silo_SetStatus())
FreeCADGui.addCommand("Silo_Settings", Silo_Settings())
FreeCADGui.addCommand("Silo_ToggleMode", Silo_ToggleMode())
FreeCADGui.addCommand("Silo_Auth", Silo_Auth())

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,