Improve Autodesk connector browsing and progress UI

Sort hubs, projects, folders and files alphabetically. Allow
multi-select when adding models so several can be pulled at once.
Rework the progress dialog into a fixed-shape two-line layout that
shows percent and byte counts, middle-eliding long filenames so the
window never reflows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-20 15:37:07 +10:00
parent 49f6f64e5c
commit 8734b419e5
3 changed files with 192 additions and 72 deletions
@@ -250,7 +250,7 @@ class ApsClient:
def list_hubs(self) -> list[dict[str, Any]]:
payload = self._get_json("https://developer.api.autodesk.com/project/v1/hubs")
return [
hubs = [
{
"id": item["id"],
"name": item["attributes"]["name"],
@@ -258,6 +258,8 @@ class ApsClient:
}
for item in payload.get("data", [])
]
hubs.sort(key=lambda h: (h["name"] or "").casefold())
return hubs
def list_projects(self, hub_id: str) -> list[dict[str, Any]]:
url = f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects"
@@ -274,13 +276,16 @@ class ApsClient:
}
)
url = payload.get("links", {}).get("next", {}).get("href", "") or ""
projects.sort(key=lambda p: (p["name"] or "").casefold())
return projects
def list_top_folders(self, hub_id: str, project_id: str) -> list[dict[str, Any]]:
payload = self._get_json(
f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects/{project_id}/topFolders"
)
return [self._entry(item) for item in payload.get("data", [])]
folders = [self._entry(item) for item in payload.get("data", [])]
folders.sort(key=lambda e: (e.get("display_name") or "").casefold())
return folders
def list_folder_contents(
self,
@@ -303,6 +308,7 @@ class ApsClient:
continue
entries.append(entry)
url = payload.get("links", {}).get("next", {}).get("href", "") or ""
entries.sort(key=lambda e: (e.get("display_name") or "").casefold())
return entries
def get_item(self, project_id: str, item_id: str) -> dict[str, Any]:
@@ -364,7 +370,7 @@ class ApsClient:
storage_id: str,
destination_path: Path,
*,
progress: Callable[[str, int | None], None] | None = None,
progress: Callable[[str, int | None, int | None, int | None], None] | None = None,
) -> None:
bucket_key, object_key = self._parse_storage_id(storage_id)
signed_url = self._get_signed_download_url(bucket_key, object_key)
@@ -377,7 +383,7 @@ class ApsClient:
local_path: Path,
*,
display_name: str | None = None,
progress: Callable[[str, int | None], None] | None = None,
progress: Callable[[str, int | None, int | None, int | None], None] | None = None,
) -> dict[str, Any]:
if not local_path.exists():
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Local file '{local_path}' does not exist.")
@@ -439,12 +445,12 @@ class ApsClient:
self,
url: str,
destination_path: Path,
progress: Callable[[str, int | None], None] | None,
progress: Callable[[str, int | None, int | None, int | None], None] | None,
) -> None:
try:
with self.http.stream("GET", url) as response:
response.raise_for_status()
total_bytes = None
total_bytes: int | None = None
header_value = response.headers.get("Content-Length")
if header_value and header_value.isdigit():
total_bytes = int(header_value)
@@ -455,9 +461,9 @@ class ApsClient:
downloaded_bytes += len(chunk)
if progress and total_bytes:
percent = min(100, int((downloaded_bytes / total_bytes) * 100))
progress(destination_path.name, percent)
progress(destination_path.name, percent, downloaded_bytes, total_bytes)
elif progress:
progress(destination_path.name, None)
progress(destination_path.name, None, downloaded_bytes, total_bytes)
except httpx.HTTPStatusError as exc:
body = exc.response.text.strip()
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
@@ -486,7 +492,7 @@ class ApsClient:
bucket_key: str,
object_key: str,
local_path: Path,
progress: Callable[[str, int | None], None] | None,
progress: Callable[[str, int | None, int | None, int | None], None] | None,
) -> None:
file_size = local_path.stat().st_size
chunk_size = 5 * 1024 * 1024
@@ -522,7 +528,7 @@ class ApsClient:
bytes_uploaded += len(chunk)
if progress:
percent = 100 if file_size == 0 else min(100, int((bytes_uploaded / file_size) * 100))
progress(local_path.name, percent)
progress(local_path.name, percent, bytes_uploaded, file_size)
if not upload_key:
raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload did not return an upload key.")
@@ -11,25 +11,51 @@ from bonsaiviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, JSONRPC_INVALID_PA
from bonsaiviewer_autodesk.ui import BrowseDialog, SettingsDialog, progress_dialog, prompt_for_filename
ApsProgress = Callable[[str, "int | None"], None]
Report = Callable[[str, str, "int | None"], None]
ApsProgress = Callable[[str, "int | None", "int | None", "int | None"], None]
Report = Callable[[str, str, "int | None", "str | None"], None]
def _format_bytes(value: int) -> str:
"""Render a byte count as a short human-readable string (e.g. '3.4 MB')."""
if value < 1024:
return f"{value} B"
scaled = float(value)
for unit in ("KB", "MB", "GB", "TB"):
scaled /= 1024.0
if scaled < 1024 or unit == "TB":
return f"{scaled:.1f} {unit}"
return f"{value} B"
def _progress_detail(percent: int | None, done: int | None, total: int | None) -> str:
"""Build the stats line shown beneath the filename, e.g. '45%, 4.5 MB / 10.0 MB'."""
parts: list[str] = []
if percent is not None:
parts.append(f"{percent}%")
if done is not None and total:
parts.append(f"{_format_bytes(done)} / {_format_bytes(total)}")
elif done is not None:
parts.append(_format_bytes(done))
return ", ".join(parts)
def _download_callback(report: Report, index: int = 0, total: int = 0) -> ApsProgress:
"""Adapt ProgressDialog.report (3-arg) to the APS download callback (2-arg).
"""Adapt ProgressDialog.report to the APS download callback.
index/total render "(i/N)" suffix when batching; pass 0 (the default) for
single-file downloads to omit the suffix.
"""
def cb(name: str, percent: int | None) -> None:
def cb(name: str, percent: int | None, bytes_done: int | None, bytes_total: int | None) -> None:
suffix = f" ({index}/{total})" if total else ""
report("download", f"Downloading {name}{suffix}", percent)
detail = _progress_detail(percent, bytes_done, bytes_total)
report("download", f"Downloading {name}{suffix}", percent, detail)
return cb
def _upload_callback(report: Report) -> ApsProgress:
def cb(name: str, percent: int | None) -> None:
report("upload", f"Uploading {name}", percent)
def cb(name: str, percent: int | None, bytes_done: int | None, bytes_total: int | None) -> None:
detail = _progress_detail(percent, bytes_done, bytes_total)
report("upload", f"Uploading {name}", percent, detail)
return cb
@@ -95,7 +121,7 @@ class AutodeskConnector:
chosen = BrowseDialog(auth=auth, aps=aps, mode="ifcfed").run()
hub = chosen["hub"]
project = chosen["project"]
entry = chosen["entry"]
entry = chosen["entries"][0]
with progress_dialog("Downloading project") as report:
path = self._download_ifcfed(
aps=aps,
@@ -230,10 +256,37 @@ class AutodeskConnector:
chosen = BrowseDialog(auth=auth, aps=aps, mode="model").run()
hub = chosen["hub"]
project = chosen["project"]
entry = chosen["entry"]
entries = chosen["entries"]
results: list[dict[str, Any]] = []
total = len(entries)
with progress_dialog("Downloading models") as report:
for index, entry in enumerate(entries):
callback = _download_callback(report, index=index + 1, total=total)
try:
result = self._download_picked_model(aps, hub, project, entry, callback)
except RpcError as exc:
print(f"pull_models_interactive[{index}] skipped: {exc.message}", file=sys.stderr)
continue
except Exception as exc:
print(f"pull_models_interactive[{index}] skipped: {exc}", file=sys.stderr)
traceback.print_exc(file=sys.stderr)
continue
if result is not None:
results.append(result)
return results
def _download_picked_model(
self,
aps: ApsClient,
hub: dict[str, Any],
project: dict[str, Any],
entry: dict[str, Any],
progress: ApsProgress,
) -> dict[str, Any] | None:
item = aps.get_item(project["id"], entry["id"])
if item["hidden"]:
raise RpcError(JSONRPC_INTERNAL_ERROR, "The selected Autodesk item has been deleted.")
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{entry['id']}' has been deleted.")
storage_id = item["storage_id"]
if not isinstance(storage_id, str):
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{entry['id']}' has no downloadable storage.")
@@ -245,24 +298,19 @@ class AutodeskConnector:
model_path = directory / file_name
if not model_path.exists():
cache.prepare_sole_child_dir(directory)
with progress_dialog("Downloading model") as report:
aps.download_storage_to_file(
storage_id, model_path, progress=_download_callback(report)
)
aps.download_storage_to_file(storage_id, model_path, progress=progress)
return [
{
"display_name": file_name,
"source": {
"connector": CONNECTOR_ID,
"hub_id": hub["id"],
"project_id": project["id"],
"item_id": entry["id"],
},
"path": str(model_path),
"metadata": _build_metadata(item),
}
]
return {
"display_name": file_name,
"source": {
"connector": CONNECTOR_ID,
"hub_id": hub["id"],
"project_id": project["id"],
"item_id": entry["id"],
},
"path": str(model_path),
"metadata": _build_metadata(item),
}
# ---- push_ifcfed_interactive --------------------------------------------
@@ -278,7 +326,7 @@ class AutodeskConnector:
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
hub = chosen["hub"]
project = chosen["project"]
folder = chosen["entry"]
folder = chosen["entries"][0]
file_name = prompt_for_filename(
title="Save Project",
@@ -378,7 +426,7 @@ class AutodeskConnector:
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
hub = chosen["hub"]
project = chosen["project"]
folder = chosen["entry"]
folder = chosen["entries"][0]
file_name = prompt_for_filename(
title="Save Model",
@@ -110,28 +110,57 @@ class _BaseDialog(ctk.CTkToplevel):
class ProgressDialog(_BaseDialog):
"""Fixed-size progress dialog: a title line, a stats line, and a bar.
Both text lines are single-line and middle-elided with '' so a long
filename can never reflow the layout or resize the window.
"""
_WIDTH = 560
def __init__(self, title: str = "Working", parent: tk.Misc | None = None) -> None:
super().__init__(title, size=(440, 130), resizable=False)
super().__init__(title, size=(self._WIDTH, 160), resizable=False)
self._text_font = ctk.CTkFont()
body = ctk.CTkFrame(self)
body.pack(fill="both", expand=True, padx=20, pady=20)
self.message = ctk.CTkLabel(body, text="Working…", anchor="w")
self.message.pack(fill="x", anchor="w")
self.title_label = ctk.CTkLabel(body, text="Working…", anchor="w", font=self._text_font)
self.title_label.pack(fill="x", padx=16, pady=(16, 0))
# Initialised with a space so the line reserves its height before the
# first report(); a single-line label never grows taller than this.
self.detail_label = ctk.CTkLabel(body, text=" ", anchor="w", font=self._text_font)
self.detail_label.pack(fill="x", padx=16, pady=(2, 0))
self.bar = ctk.CTkProgressBar(body, mode="indeterminate")
self.bar.pack(fill="x", pady=(12, 0))
self.bar.pack(fill="x", padx=16, pady=(14, 16))
self.bar.start()
self._determinate = False
# Derive the height from the laid-out content (font-driven) rather than
# hardcoding it, then lock it. Single-line labels keep it stable no
# matter how long the text is.
self.update_idletasks()
self.geometry(f"{self._WIDTH}x{self.winfo_reqheight()}")
self._center_on_screen()
self.deiconify()
self.lift()
self.update()
def report(self, _phase: str, message: str, percent: int | None = None) -> None:
def report(
self,
_phase: str,
message: str,
percent: int | None = None,
detail: str | None = None,
) -> None:
try:
self.message.configure(text=message)
width = self._text_area_width()
self.title_label.configure(text=self._elide_middle(message, width))
self.detail_label.configure(text=self._elide_middle(detail or " ", width))
if percent is None:
if self._determinate:
self.bar.configure(mode="indeterminate")
@@ -147,6 +176,29 @@ class ProgressDialog(_BaseDialog):
except tk.TclError:
pass
def _text_area_width(self) -> int:
"""Pixels available for label text, in the same scaled space as the font."""
width = self.title_label.winfo_width()
if width <= 1: # not laid out yet
return self._WIDTH - 2 * 20 - 2 * 16
return max(40, width - 6)
def _elide_middle(self, text: str, max_width: int) -> str:
"""Middle-truncate text with '' so it fits max_width without wrapping."""
font = self._text_font
if font.measure(text) <= max_width:
return text
ellipsis = ""
keep = len(text) - 1
while keep > 0:
head = (keep + 1) // 2
tail = keep - head
candidate = text[:head] + ellipsis + (text[-tail:] if tail else "")
if font.measure(candidate) <= max_width:
return candidate
keep -= 1
return ellipsis
class _ProgressContext:
def __init__(self, parent: tk.Misc | None, message: str) -> None:
@@ -154,7 +206,7 @@ class _ProgressContext:
self.message = message
self.dialog: ProgressDialog | None = None
def __enter__(self) -> Callable[[str, str, int | None], None]:
def __enter__(self) -> Callable[..., None]:
self.dialog = ProgressDialog(self.message, self.parent)
return self.dialog.report
@@ -197,9 +249,10 @@ class BrowseDialog(_BaseDialog):
self.auth = auth
self.aps = aps
self.mode: Mode = mode
self.multi_select = mode == "model"
self.selected_hub: dict[str, Any] | None = None
self.selected_project: dict[str, Any] | None = None
self.selected_entry: dict[str, Any] | None = None
self.selected_entries: list[dict[str, Any]] = []
self._tree_entries: dict[str, dict[str, Any]] = {}
self._project_entries: dict[str, dict[str, Any]] = {}
@@ -241,7 +294,8 @@ class BrowseDialog(_BaseDialog):
self.projects = self._make_treeview(self.projects_frame, "PROJECTS")
self.projects.bind("<<TreeviewSelect>>", lambda _e: self._project_changed())
self.tree = self._make_treeview(self.tree_frame, "FOLDERS")
tree_selectmode = "extended" if self.multi_select else "browse"
self.tree = self._make_treeview(self.tree_frame, "FOLDERS", selectmode=tree_selectmode)
self.tree.bind("<<TreeviewSelect>>", lambda _e: self._tree_selection_changed())
self.tree.bind("<<TreeviewOpen>>", self._on_tree_open)
@@ -257,14 +311,14 @@ class BrowseDialog(_BaseDialog):
self.action_button.grid(row=0, column=2)
self.action_button.configure(state="disabled")
def _make_treeview(self, parent: ctk.CTkFrame, header: str) -> ttk.Treeview:
def _make_treeview(self, parent: ctk.CTkFrame, header: str, selectmode: str = "browse") -> ttk.Treeview:
ctk.CTkLabel(parent, text=header, anchor="w").pack(fill="x", padx=12, pady=(8, 0))
body = ctk.CTkFrame(parent, fg_color="transparent")
body.pack(fill="both", expand=True, padx=8, pady=8)
body.grid_rowconfigure(0, weight=1)
body.grid_columnconfigure(0, weight=1)
tree = ttk.Treeview(body, show="tree", selectmode="browse")
tree = ttk.Treeview(body, show="tree", selectmode=selectmode)
tree.grid(row=0, column=0, sticky="nsew")
scrollbar = ctk.CTkScrollbar(body, orientation="vertical", command=tree.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
@@ -336,7 +390,7 @@ class BrowseDialog(_BaseDialog):
if not isinstance(project, dict):
return
self.selected_project = project
self.selected_entry = None
self.selected_entries = []
self._refresh_action_button()
self._clear_tree()
with self._with_progress("Loading top folders"):
@@ -400,41 +454,53 @@ class BrowseDialog(_BaseDialog):
def _tree_selection_changed(self) -> None:
selection = self.tree.selection()
entry = self._tree_entries.get(selection[0]) if selection else None
if isinstance(entry, dict) and not entry.get("__placeholder__"):
self.selected_entry = entry
entries: list[dict[str, Any]] = []
for iid in selection:
entry = self._tree_entries.get(iid)
if isinstance(entry, dict) and not entry.get("__placeholder__"):
entries.append(entry)
self.selected_entries = entries
if not entries:
pass
elif len(entries) == 1:
entry = entries[0]
name = entry.get("display_name") or entry.get("name") or entry.get("id", "?")
kind = entry.get("type", "entry")
self.status.configure(text=f"Selected {kind}: {name}")
else:
self.selected_entry = None
valid_count = sum(1 for e in entries if self._is_valid_selection(e))
self.status.configure(text=f"Selected {valid_count} of {len(entries)} items.")
self._refresh_action_button()
def _is_valid_selection(self, entry: dict[str, Any]) -> bool:
if self.mode == "destination":
return entry.get("type") == "folders"
if self.mode == "ifcfed":
return (
entry.get("type") == "items"
and (entry.get("display_name") or "").lower().endswith(".ifcfed")
)
return (
entry.get("type") == "items"
and (entry.get("display_name") or "").lower().endswith(MODEL_EXTENSIONS)
)
def _valid_entries(self) -> list[dict[str, Any]]:
return [e for e in self.selected_entries if self._is_valid_selection(e)]
def _refresh_action_button(self) -> None:
enabled = self.selected_project is not None and self.selected_entry is not None
if enabled:
assert self.selected_entry is not None
if self.mode == "destination":
enabled = self.selected_entry.get("type") == "folders"
elif self.mode == "ifcfed":
enabled = (
self.selected_entry.get("type") == "items"
and (self.selected_entry.get("display_name") or "").lower().endswith(".ifcfed")
)
else:
enabled = (
self.selected_entry.get("type") == "items"
and (self.selected_entry.get("display_name") or "").lower().endswith(MODEL_EXTENSIONS)
)
enabled = self.selected_project is not None and bool(self._valid_entries())
self.action_button.configure(state="normal" if enabled else "disabled")
def _confirm(self) -> None:
if not self.selected_hub or not self.selected_project or not self.selected_entry:
valid = self._valid_entries()
if not self.selected_hub or not self.selected_project or not valid:
return
self.result = {
"hub": self.selected_hub,
"project": self.selected_project,
"entry": self.selected_entry,
"entries": valid,
}
self._on_close()