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]]: def list_hubs(self) -> list[dict[str, Any]]:
payload = self._get_json("https://developer.api.autodesk.com/project/v1/hubs") payload = self._get_json("https://developer.api.autodesk.com/project/v1/hubs")
return [ hubs = [
{ {
"id": item["id"], "id": item["id"],
"name": item["attributes"]["name"], "name": item["attributes"]["name"],
@@ -258,6 +258,8 @@ class ApsClient:
} }
for item in payload.get("data", []) 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]]: def list_projects(self, hub_id: str) -> list[dict[str, Any]]:
url = f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects" 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 "" url = payload.get("links", {}).get("next", {}).get("href", "") or ""
projects.sort(key=lambda p: (p["name"] or "").casefold())
return projects return projects
def list_top_folders(self, hub_id: str, project_id: str) -> list[dict[str, Any]]: def list_top_folders(self, hub_id: str, project_id: str) -> list[dict[str, Any]]:
payload = self._get_json( payload = self._get_json(
f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects/{project_id}/topFolders" 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( def list_folder_contents(
self, self,
@@ -303,6 +308,7 @@ class ApsClient:
continue continue
entries.append(entry) entries.append(entry)
url = payload.get("links", {}).get("next", {}).get("href", "") or "" url = payload.get("links", {}).get("next", {}).get("href", "") or ""
entries.sort(key=lambda e: (e.get("display_name") or "").casefold())
return entries return entries
def get_item(self, project_id: str, item_id: str) -> dict[str, Any]: def get_item(self, project_id: str, item_id: str) -> dict[str, Any]:
@@ -364,7 +370,7 @@ class ApsClient:
storage_id: str, storage_id: str,
destination_path: Path, destination_path: Path,
*, *,
progress: Callable[[str, int | None], None] | None = None, progress: Callable[[str, int | None, int | None, int | None], None] | None = None,
) -> None: ) -> None:
bucket_key, object_key = self._parse_storage_id(storage_id) bucket_key, object_key = self._parse_storage_id(storage_id)
signed_url = self._get_signed_download_url(bucket_key, object_key) signed_url = self._get_signed_download_url(bucket_key, object_key)
@@ -377,7 +383,7 @@ class ApsClient:
local_path: Path, local_path: Path,
*, *,
display_name: str | None = None, 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]: ) -> dict[str, Any]:
if not local_path.exists(): if not local_path.exists():
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Local file '{local_path}' does not exist.") raise RpcError(JSONRPC_INTERNAL_ERROR, f"Local file '{local_path}' does not exist.")
@@ -439,12 +445,12 @@ class ApsClient:
self, self,
url: str, url: str,
destination_path: Path, destination_path: Path,
progress: Callable[[str, int | None], None] | None, progress: Callable[[str, int | None, int | None, int | None], None] | None,
) -> None: ) -> None:
try: try:
with self.http.stream("GET", url) as response: with self.http.stream("GET", url) as response:
response.raise_for_status() response.raise_for_status()
total_bytes = None total_bytes: int | None = None
header_value = response.headers.get("Content-Length") header_value = response.headers.get("Content-Length")
if header_value and header_value.isdigit(): if header_value and header_value.isdigit():
total_bytes = int(header_value) total_bytes = int(header_value)
@@ -455,9 +461,9 @@ class ApsClient:
downloaded_bytes += len(chunk) downloaded_bytes += len(chunk)
if progress and total_bytes: if progress and total_bytes:
percent = min(100, int((downloaded_bytes / total_bytes) * 100)) 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: elif progress:
progress(destination_path.name, None) progress(destination_path.name, None, downloaded_bytes, total_bytes)
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
body = exc.response.text.strip() body = exc.response.text.strip()
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
@@ -486,7 +492,7 @@ class ApsClient:
bucket_key: str, bucket_key: str,
object_key: str, object_key: str,
local_path: Path, local_path: Path,
progress: Callable[[str, int | None], None] | None, progress: Callable[[str, int | None, int | None, int | None], None] | None,
) -> None: ) -> None:
file_size = local_path.stat().st_size file_size = local_path.stat().st_size
chunk_size = 5 * 1024 * 1024 chunk_size = 5 * 1024 * 1024
@@ -522,7 +528,7 @@ class ApsClient:
bytes_uploaded += len(chunk) bytes_uploaded += len(chunk)
if progress: if progress:
percent = 100 if file_size == 0 else min(100, int((bytes_uploaded / file_size) * 100)) 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: if not upload_key:
raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload did not return an 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 from bonsaiviewer_autodesk.ui import BrowseDialog, SettingsDialog, progress_dialog, prompt_for_filename
ApsProgress = Callable[[str, "int | None"], None] ApsProgress = Callable[[str, "int | None", "int | None", "int | None"], None]
Report = Callable[[str, str, "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: 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 index/total render "(i/N)" suffix when batching; pass 0 (the default) for
single-file downloads to omit the suffix. 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 "" 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 return cb
def _upload_callback(report: Report) -> ApsProgress: def _upload_callback(report: Report) -> ApsProgress:
def cb(name: str, percent: int | None) -> None: def cb(name: str, percent: int | None, bytes_done: int | None, bytes_total: int | None) -> None:
report("upload", f"Uploading {name}", percent) detail = _progress_detail(percent, bytes_done, bytes_total)
report("upload", f"Uploading {name}", percent, detail)
return cb return cb
@@ -95,7 +121,7 @@ class AutodeskConnector:
chosen = BrowseDialog(auth=auth, aps=aps, mode="ifcfed").run() chosen = BrowseDialog(auth=auth, aps=aps, mode="ifcfed").run()
hub = chosen["hub"] hub = chosen["hub"]
project = chosen["project"] project = chosen["project"]
entry = chosen["entry"] entry = chosen["entries"][0]
with progress_dialog("Downloading project") as report: with progress_dialog("Downloading project") as report:
path = self._download_ifcfed( path = self._download_ifcfed(
aps=aps, aps=aps,
@@ -230,10 +256,37 @@ class AutodeskConnector:
chosen = BrowseDialog(auth=auth, aps=aps, mode="model").run() chosen = BrowseDialog(auth=auth, aps=aps, mode="model").run()
hub = chosen["hub"] hub = chosen["hub"]
project = chosen["project"] 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"]) item = aps.get_item(project["id"], entry["id"])
if item["hidden"]: 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"] storage_id = item["storage_id"]
if not isinstance(storage_id, str): if not isinstance(storage_id, str):
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{entry['id']}' has no downloadable storage.") 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 model_path = directory / file_name
if not model_path.exists(): if not model_path.exists():
cache.prepare_sole_child_dir(directory) cache.prepare_sole_child_dir(directory)
with progress_dialog("Downloading model") as report: aps.download_storage_to_file(storage_id, model_path, progress=progress)
aps.download_storage_to_file(
storage_id, model_path, progress=_download_callback(report)
)
return [ return {
{ "display_name": file_name,
"display_name": file_name, "source": {
"source": { "connector": CONNECTOR_ID,
"connector": CONNECTOR_ID, "hub_id": hub["id"],
"hub_id": hub["id"], "project_id": project["id"],
"project_id": project["id"], "item_id": entry["id"],
"item_id": entry["id"], },
}, "path": str(model_path),
"path": str(model_path), "metadata": _build_metadata(item),
"metadata": _build_metadata(item), }
}
]
# ---- push_ifcfed_interactive -------------------------------------------- # ---- push_ifcfed_interactive --------------------------------------------
@@ -278,7 +326,7 @@ class AutodeskConnector:
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run() chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
hub = chosen["hub"] hub = chosen["hub"]
project = chosen["project"] project = chosen["project"]
folder = chosen["entry"] folder = chosen["entries"][0]
file_name = prompt_for_filename( file_name = prompt_for_filename(
title="Save Project", title="Save Project",
@@ -378,7 +426,7 @@ class AutodeskConnector:
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run() chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
hub = chosen["hub"] hub = chosen["hub"]
project = chosen["project"] project = chosen["project"]
folder = chosen["entry"] folder = chosen["entries"][0]
file_name = prompt_for_filename( file_name = prompt_for_filename(
title="Save Model", title="Save Model",
@@ -110,28 +110,57 @@ class _BaseDialog(ctk.CTkToplevel):
class ProgressDialog(_BaseDialog): 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: 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 = ctk.CTkFrame(self)
body.pack(fill="both", expand=True, padx=20, pady=20) body.pack(fill="both", expand=True, padx=20, pady=20)
self.message = ctk.CTkLabel(body, text="Working…", anchor="w") self.title_label = ctk.CTkLabel(body, text="Working…", anchor="w", font=self._text_font)
self.message.pack(fill="x", anchor="w") 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 = 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.bar.start()
self._determinate = False 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._center_on_screen()
self.deiconify() self.deiconify()
self.lift() self.lift()
self.update() 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: 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 percent is None:
if self._determinate: if self._determinate:
self.bar.configure(mode="indeterminate") self.bar.configure(mode="indeterminate")
@@ -147,6 +176,29 @@ class ProgressDialog(_BaseDialog):
except tk.TclError: except tk.TclError:
pass 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: class _ProgressContext:
def __init__(self, parent: tk.Misc | None, message: str) -> None: def __init__(self, parent: tk.Misc | None, message: str) -> None:
@@ -154,7 +206,7 @@ class _ProgressContext:
self.message = message self.message = message
self.dialog: ProgressDialog | None = None 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) self.dialog = ProgressDialog(self.message, self.parent)
return self.dialog.report return self.dialog.report
@@ -197,9 +249,10 @@ class BrowseDialog(_BaseDialog):
self.auth = auth self.auth = auth
self.aps = aps self.aps = aps
self.mode: Mode = mode self.mode: Mode = mode
self.multi_select = mode == "model"
self.selected_hub: dict[str, Any] | None = None self.selected_hub: dict[str, Any] | None = None
self.selected_project: 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._tree_entries: dict[str, dict[str, Any]] = {}
self._project_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 = self._make_treeview(self.projects_frame, "PROJECTS")
self.projects.bind("<<TreeviewSelect>>", lambda _e: self._project_changed()) 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("<<TreeviewSelect>>", lambda _e: self._tree_selection_changed())
self.tree.bind("<<TreeviewOpen>>", self._on_tree_open) 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.grid(row=0, column=2)
self.action_button.configure(state="disabled") 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)) ctk.CTkLabel(parent, text=header, anchor="w").pack(fill="x", padx=12, pady=(8, 0))
body = ctk.CTkFrame(parent, fg_color="transparent") body = ctk.CTkFrame(parent, fg_color="transparent")
body.pack(fill="both", expand=True, padx=8, pady=8) body.pack(fill="both", expand=True, padx=8, pady=8)
body.grid_rowconfigure(0, weight=1) body.grid_rowconfigure(0, weight=1)
body.grid_columnconfigure(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") tree.grid(row=0, column=0, sticky="nsew")
scrollbar = ctk.CTkScrollbar(body, orientation="vertical", command=tree.yview) scrollbar = ctk.CTkScrollbar(body, orientation="vertical", command=tree.yview)
scrollbar.grid(row=0, column=1, sticky="ns") scrollbar.grid(row=0, column=1, sticky="ns")
@@ -336,7 +390,7 @@ class BrowseDialog(_BaseDialog):
if not isinstance(project, dict): if not isinstance(project, dict):
return return
self.selected_project = project self.selected_project = project
self.selected_entry = None self.selected_entries = []
self._refresh_action_button() self._refresh_action_button()
self._clear_tree() self._clear_tree()
with self._with_progress("Loading top folders"): with self._with_progress("Loading top folders"):
@@ -400,41 +454,53 @@ class BrowseDialog(_BaseDialog):
def _tree_selection_changed(self) -> None: def _tree_selection_changed(self) -> None:
selection = self.tree.selection() selection = self.tree.selection()
entry = self._tree_entries.get(selection[0]) if selection else None entries: list[dict[str, Any]] = []
if isinstance(entry, dict) and not entry.get("__placeholder__"): for iid in selection:
self.selected_entry = entry 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", "?") name = entry.get("display_name") or entry.get("name") or entry.get("id", "?")
kind = entry.get("type", "entry") kind = entry.get("type", "entry")
self.status.configure(text=f"Selected {kind}: {name}") self.status.configure(text=f"Selected {kind}: {name}")
else: 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() 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: def _refresh_action_button(self) -> None:
enabled = self.selected_project is not None and self.selected_entry is not None enabled = self.selected_project is not None and bool(self._valid_entries())
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)
)
self.action_button.configure(state="normal" if enabled else "disabled") self.action_button.configure(state="normal" if enabled else "disabled")
def _confirm(self) -> None: 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 return
self.result = { self.result = {
"hub": self.selected_hub, "hub": self.selected_hub,
"project": self.selected_project, "project": self.selected_project,
"entry": self.selected_entry, "entries": valid,
} }
self._on_close() self._on_close()