Skip to content

Refactor Browser - #2031

Open
BigRoy wants to merge 299 commits into
ui-qt-luncherfrom
ui-qt-browser-refactor
Open

Refactor Browser#2031
BigRoy wants to merge 299 commits into
ui-qt-luncherfrom
ui-qt-browser-refactor

Conversation

@BigRoy

@BigRoy BigRoy commented Sep 1, 2026

Copy link
Copy Markdown
Member

Changelog Description

  • Separate legacy loader from new browser
  • Show hero versions in browser
  • Allow to switch versions in the row directly by double clicking version cell (like old loader)
  • Add explicit working view defaults
  • Add an interface contract so that custom addons can add new columns/filers, e.g. Site Sync.
  • Add columns behavior now matches more with latest frontend
  • Filter bar has been tweaked to match more with latest frontend
  • Optimize queries for group by where possible
  • Add informational overlays when nothing is selected.
  • Add "In Scene" column functionality to show which versions are already loaded
  • Add "Go to current context" button when in a DCC (when you have a context)
  • Allow to hide/show columns + reorder columns
  • Set AYON_USE_LEGACY_LOADER=1 env var to use the legacy loader (we may want to reverse this at first)
  • Refactor 'Review' terminology to 'Browser'

Additional info

This won't be an easy one to review because well, it's massive. And it still requires a lot of cleanup - but I've focused on just throwing things together to get the UI to a functional state with admittedly spending a small amount of time only on cleanup.

It'd be great to review it by:

  • Testing it
  • Then do some glancing over the code and ask questions about things that are entirely stupid.
  • Only once we're getting to a state that we're all somewhat happy I think we should sit down and do a thorough review of everything.

Also, I'm sure the tests are shit ;) I've not read them at all and I let AI do that along the side just to give it some wheels.

And, I'm so sorry!

TODO

  • Filter bar should not extend the UI size
  • Add Columns menu has issues with the focus (doesn’t auto-expand)
  • Group By:
    • Improve querying: probably not query groups separately, just query the versions and group them UI side.
    • Show empty groups (does not filter out the empty groups?)
  • Fix invalid query being done on selecting a group (for the side panel)
  • Toggle between old or new browser via setting in core
    • Allow an env var to override the setting
  • Add "My Tasks" filtering to the slicer?
  • Loader inside a DCC is not respecting Library attribute and shows all projects by default
  • When Loader/Browser is launched for the very first time, it is empty (no folders). Closing it and running it again will populate the data, or selecting a different project and going back will refresh the data
  • LoaderController + BrowserController should not both exist in the browser separated between BrowserController & BrowserWidgetController (QObject for signals) for now; needs more @iLLiCiTiT review.
  • Change the columns interface around so that addon doesn't need to expose a method that requires Qt - avoid the Qt requirement + maybe change it around for something else to 'register something with core', so there some core.x.y.register_column_handler() Removed the public interface API for now
  • Add Count on group by on table view
  • views list UI cosmetics
  • Test site sync integration with custom columns
  • Add a means to group by 'productGroup' Possibly better with a server-side migration script that moves it to a regular attribute.
  • Add a hardcore limit of never displaying more than e.g. 1000 versions with a little notice to recommend the user to perform tighter filtering to avoid tons of paginated queries? Skipping for now because we're paginating when we can.
  • Test Power pack-less views access groups?
    • Perhaps add additional license check for power-pack?
  • Fix Tasks "name" filter disappearing on folder switch due to tasks widget bottom left
  • Ensure all relevant toggles, like e.g. "My Tasks" and "Group By" are stored within the Views, and when resetting the working view to "default" that it also resets both of these.

Testing notes:

  1. Cry for a bit that you've been assigned reviewer here.
  2. Test it functionally
  3. Glance over code and ask questions, point out obvious things.

Only then after this PR has gone through initial tests, then we should go over thorough code review.

philippe-ynput and others added 30 commits April 3, 2026 10:42
…d key validation

Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Remove the fetch_versions_page_batch method and simplify to use individual
page fetches. Add page_fetched signal handler to process events and keep
UI responsive during large data fetches.

Signed-off-by: philippe-ynput <philippe@ynput.io>
…-browser

Signed-off-by: philippe-ynput <philippe@ynput.io>
- Set tree mode to enabled by default in ReviewController
- Remove tree mode toggle checkbox from ReviewTable UI
- Remove tree mode toggle handler and related category change logic
- Simplifies the UI by always showing hierarchy view

Signed-off-by: philippe-ynput <philippe@ynput.io>
Adds a new `log_timing` context manager that logs the execution time of code blocks
when the AYON_CORE_TIMERS environment variable is enabled. This helps identify
performance bottlenecks in the codebase.

Signed-off-by: philippe-ynput <philippe@ynput.io>
Introduce product grouping feature allowing versions to be grouped by product, product type, or status. Add GraphQL query for fetching products, GroupBy enum with icon mappings, and empty row template. Include UI controls (menu, checkbox) in reviews widget. Add log_timing wrapper for thumbnail fetching and comprehensive tests for the review controller.

Signed-off-by: philippe-ynput <philippe@ynput.io>
Replace hardcoded "Hierarchy" and "Reviews" strings with ReviewCategory
enum values across review_controller.py, reviews_widget.py. This improves
type safety and makes category comparisons less error-prone.

Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Replace `_visible_proxy_range()` with `_iter_visible_proxy_indices()` that uses
QtCore.QPersistentModelIndex for reliable row tracking and deduplication.
Also replace string literal "Hierarchy" with ReviewCategory.HIERARCHY enum.

Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Implement right-click context menu on the review table that displays loader
action items. Added get_action_items and trigger_action_item methods to
ReviewController for delegating to the main loader controller. Updated
ReviewsWidget to accept a loader_controller and display the actions menu
on context menu request.

Signed-off-by: philippe-ynput <philippe@ynput.io>
Add featuredVersion query and populate group header rows with
version metadata including thumbnail, status, author, and timestamps

Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
…`ayon_ui_qt`, branch `browser-ui-phase-2`

Signed-off-by: philippe-ynput <philippe@ynput.io>
Change GroupByMenu inheritance from AYButtonMenu to AYFilter and
implement custom dropdown popup for group by selection UI.

Signed-off-by: philippe-ynput <philippe@ynput.io>
…omize menu

- Rename GroupBy enum to GroupByOption for better clarity
- Move group-by functionality into new Customize button menu extending AYButtonMenu
- Add get_attribute_icon function for mapping attribute types to icons
- Fix typo: "deprioritises" to "deprioritize" in VisibilityAwarePaginatedTableModel docstring

Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
… (rebase)

Signed-off-by: philippe-ynput <philippe@ynput.io>
Introduce a new card view mode alongside the existing table view in the ReviewTable widget. Users can now switch between table and card layouts via the display type selector. The card view uses a custom mapper to display review data with headers, titles, status, versions, and thumbnails.

Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Set initial tree mode based on group_by_key and update dynamically
when group_by changes, removing redundant controller/model calls.

BREAKING CHANGE: tree mode now depends on group_by_key being non-"none"

Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
…ng logic

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: philippe-ynput <philippe@ynput.io>
… view

Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Comment on lines +230 to +231
len(order) != len(options_by_text)
or set(order) != set(options_by_text)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
len(order) != len(options_by_text)
or set(order) != set(options_by_text)
set(order) != set(options_by_text)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was actually added due to copilot describing there's an edge case if we don't. Admittedly I have a hard time finding those Copilot comments now. I'll need to look that up again 🗡️

Comment on lines +382 to +388
(
"check"
if check_style == "checkmark"
else "check_box"
if option.checked
else "check_box_outline_blank"
),

@moonyuet moonyuet Sep 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we can use variable to store this or use if...elif..else store it But it is already many if..elif..elif...elif..else in this PR.

icon_name =  (
	      "check"
	      if check_style == "checkmark"
	      else "check_box"
	      if option.checked
	      else "check_box_outline_blank"
)

or

if check_style == "checkmark":
    icon_name = "check"
elif option.checked:
    icon_name = "check_box"
else:
    icon_name = "check_box_outline_blank"

]
self._reset_pagination()

def _get_query_filters(self) -> dict[str, Any]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BigRoy Guess we should come up with some clean up with this function to make it more readable?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At some point - yes, but not a priority honestly - more important that it works and that it's fast. Might do that once we get to the thorough code cleanup here though. Needs the features/bugs resolved first :D

Comment thread client/ayon_core/tools/browser/ui/browser_queries.py
color_off=self._icon_color,
icon_name_on=self._icon_on,
color_on=self._icon_color,
color_on=self._icon_checked_color,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't that be named ._icon_color_on?

Comment on lines +381 to +391
if dropdown_variant is None:
self._dropdown = ButtonMenuDropdown(
self, container_margin=dropdown_margin
)
else:
self._dropdown = ButtonMenuDropdown(
self,
frame_variant=dropdown_variant,
container_variant=dropdown_variant,
container_margin=dropdown_margin,
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if dropdown_variant is None:
self._dropdown = ButtonMenuDropdown(
self, container_margin=dropdown_margin
)
else:
self._dropdown = ButtonMenuDropdown(
self,
frame_variant=dropdown_variant,
container_variant=dropdown_variant,
container_margin=dropdown_margin,
)
kwargs = {}
if dropdown_variant is not None:
kwargs = dict(
frame_variant=dropdown_variant,
container_variant=dropdown_variant,
)
self._dropdown = ButtonMenuDropdown(
self,
container_margin=dropdown_margin,
**kwargs
)

"""Update state when the popup signals it has closed."""
self._menu_open = False
if QtWidgets.QApplication.mouseButtons() & Qt.MouseButton.LeftButton:
local_pos = self.mapFromGlobal(QtGui.QCursor.pos())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
local_pos = self.mapFromGlobal(QtGui.QCursor.pos())
local_pos = self.mapFromGlobal(QtGui.QCursor.pos())
if isinstance(local_pos, QtCore.QPointF):
local_pos = local_pos.toPoint()

Comment on lines +299 to +303
if isinstance(model, QSortFilterProxyModel):
source = model.sourceModel()
if isinstance(source, PaginatedTableModel):
source.fetch_more(model.mapToSource(index))
return

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if isinstance(model, QSortFilterProxyModel):
source = model.sourceModel()
if isinstance(source, PaginatedTableModel):
source.fetch_more(model.mapToSource(index))
return
while isinstance(model, QSortFilterProxyModel):
model = model.sourceModel()

label_color = meta_idx.data(Qt.ItemDataRole.ForegroundRole)
label_icon = meta_idx.data(Qt.ItemDataRole.DecorationRole)
child_count = model.rowCount(group_idx)
row_data = group_idx.data(Qt.ItemDataRole.UserRole) or {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can I use AYCardView with custom model? If yes, then why it's using Qt.ItemDataRole.UserRole to get UI related data?

That is potential danger for cusom models using UserRole for different purposes. Also how do we make sure that the data are actually stored there?

I didn't dig enough to find out what child_count, group_percentage and group_metrics are comming from and what are representing, but this doesn't look good unless the model is strictly defined.

self._schedule_editor_sync()

def _maybe_fetch_more(self) -> None:
def fetch_more_if_needed(self) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this should be publicly available.

if near_bottom and root_pmi not in fetched:
if model.canFetchMore(root):
model.fetchMore(root)
self._fetch_more(root)

@iLLiCiTiT iLLiCiTiT Sep 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why to change this to _fetch_more instead of fetchMore defined by Qt's QAbstractItemModel?

Comment on lines +95 to +109
for widget in self.take_items():
widget.deleteLater()

def take_items(self) -> list[QWidget]:
"""Remove and return items without deleting their widgets."""
widgets = [widget for widget, _match_fn in self._items]
for widget in widgets:
widget.hide()
self._items_layout.removeWidget(widget)
self._items.clear()
self._items_layout.clear()

while self._items_layout.count():
item = self._items_layout.takeAt(0)
del item
return widgets

@iLLiCiTiT iLLiCiTiT Sep 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
for widget in self.take_items():
widget.deleteLater()
def take_items(self) -> list[QWidget]:
"""Remove and return items without deleting their widgets."""
widgets = [widget for widget, _match_fn in self._items]
for widget in widgets:
widget.hide()
self._items_layout.removeWidget(widget)
self._items.clear()
self._items_layout.clear()
while self._items_layout.count():
item = self._items_layout.takeAt(0)
del item
return widgets
self._items.clear()
while self._items_layout.count():
item = self._items_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.setVisible(False)
widget.deleteLater()

I think this was already suggested.

if event.type() == QtCore.QEvent.Type.Enter:
self._frame.set_hovered(True)
elif event.type() == QtCore.QEvent.Type.Leave:
local = self._frame.mapFromGlobal(QtGui.QCursor.pos())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
local = self._frame.mapFromGlobal(QtGui.QCursor.pos())
local = self._frame.mapFromGlobal(QtGui.QCursor.pos())
if isinstance(local, QtCore.QPointF):
local = local.toPoint()

local = self._frame.mapFromGlobal(QtGui.QCursor.pos())
if not self._frame.rect().contains(local):
self._frame.set_hovered(False)
return False # never consume the event

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return False # never consume the event
# never consume the event
return False

self._variant_str = variant.value
self._bg_tint = bg_tint
self._bg_color = None
self._hover_enabled = hover_enabled

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No reason to store the value.

Considering what it does, I'd rather have helper method that would do that instead of achieve it using args.

# each axis independently against half its own dimension,
# which turns an oversized radius on a wide-but-short rect
# (e.g. a pill chip) into an ellipse instead of a stadium.
radius = min(radius, draw_rect.width() / 2.0, draw_rect.height() / 2.0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiply is faster then divide

Suggested change
radius = min(radius, draw_rect.width() / 2.0, draw_rect.height() / 2.0)
radius = min(radius, draw_rect.width() * 0.5, draw_rect.height() * 0.5)

)
# print(f"Final ratio: {final_ratio}: {result.name()}")
return result
return tuple(result.toTuple())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could just return the color? The compute_color_for_contrast takes the tuple and converts it to QColor.

@@ -195,10 +195,8 @@ def clear_layout(layout):
if widget:
# Recursively clear any layouts this widget might have

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outdated comment

return workfiles_tool

def get_loader_tool(self, parent):
def get_loader_tool(self, parent, *, use_context: bool = False):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was use_context added?

Comment on lines +577 to +592
if use_legacy is None:
from ayon_core.pipeline import get_current_project_name
from ayon_core.settings import (
get_project_settings,
get_studio_settings,
)

project_name = get_current_project_name()
if project_name:
settings = get_project_settings(project_name)
else:
settings = get_studio_settings()
use_legacy = settings["core"]["tools"]["loader"].get(
"use_legacy_loader", False
)
return use_legacy

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if use_legacy is None:
from ayon_core.pipeline import get_current_project_name
from ayon_core.settings import (
get_project_settings,
get_studio_settings,
)
project_name = get_current_project_name()
if project_name:
settings = get_project_settings(project_name)
else:
settings = get_studio_settings()
use_legacy = settings["core"]["tools"]["loader"].get(
"use_legacy_loader", False
)
return use_legacy
if use_legacy is not None:
return use_legacy
from ayon_core.pipeline import get_current_project_name
from ayon_core.settings import (
get_project_settings,
get_studio_settings,
)
project_name = get_current_project_name()
if project_name:
settings = get_project_settings(project_name)
else:
settings = get_studio_settings()
return settings["core"]["tools"]["loader"].get(
"use_legacy_loader", False
)

controller: AbstractProjectController,
parent: QtWidgets.QWidget,
handle_expected_selection: bool = False,
variant: AYComboBox.Variants = AYComboBox.Variants.Default,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to allow change of the variant on project combobox?

Comment on lines +78 to +79
# visual freeze rendering is added in Phase 3.
self._pinned_keys: set[str] = set()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this for? The comments describes "column" the variable is name "keys" and AYTableHeader does store it but does not use it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XXL type: enhancement Improvement of existing functionality or minor addition

Projects

Status: Review In Progress

Development

Successfully merging this pull request may close these issues.

9 participants