Files
cowork-local/presentation/shell/nav_rail.py
T
13e2c22067
CI / test (push) Canceled after 0s
Fix/qa defects df002 df011 (#9)
## Summary

What changed and why?

## Change Type

- [ ] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: thanhnv <thanhnv.ip@gmail.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Reviewed-on: #9
2026-09-09 16:19:31 +00:00

388 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Thanh điều hướng bên trái — R08-T10.
Bóc từ ``MainWindow``: 18 phương thức dựng và điều khiển thanh rail, cộng danh
sách RECENTS, bộ chọn project, và việc thu gọn về dải icon 54px.
Đây là **mixin**, không phải widget rời — nói thẳng để khỏi hiểu nhầm. Cả 18
phương thức đọc/ghi state của cửa sổ (``self._page_widgets``, ``self.workspace``,
``self.splitter``…). Biến thành đối tượng cộng tác thì phải viết lại từng chỗ
``self.X`` thành ``self.window.X``, tức sửa gần 300 dòng chỉ để đổi cách gọi —
rủi ro cao mà không đổi hành vi. Mixin cho được thứ đang cần: mỗi mảng nằm ở
một file, ai sửa rail thì mở file rail.
Chuyển thành widget thật khi thanh rail cần dùng lại ở cửa sổ khác — hiện chưa.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QMenu, QPushButton, QScrollArea, QSizePolicy, QSplitter, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
from ...i18n import tr
from .rail_metrics import _NAV_COLLAPSED_WIDTH, _NAV_EXPANDED_WIDTH, _NAV_MAX_CEILING, _NAV_MAX_SHARE, _NAV_MIN_WIDTH, _NavItemDelegate
from ...ui.widgets import tidy_popup
class NavRailMixin:
"""18 phương thức thanh rail. Trộn vào MainWindow."""
def _build_nav_rail(self, right, rlay) -> None:
"""Dựng toàn bộ thanh rail và ghép với vùng nội dung.
Bóc khỏi ``MainWindow.__init__`` — 162 dòng dựng rail nằm lẫn giữa
phần dựng trang và phần khởi động scheduler, nên đọc ``__init__`` là
phải lội qua cả rail mới tới được thứ mình cần.
"""
# Left nav rail — ONE FLAT LIST, no accordion. Every screen the user
# works in is one click away: the Workspace sub-views are listed
# directly instead of hiding behind an expandable parent. The two
# occasional admin destinations sit in a second, bottom-pinned list.
#
# Monitoring is the exception that keeps its sub-views OUT of the rail:
# it has eight, which would double the rail's length for screens opened
# once a week. Its own tab strip is left visible instead (it was hidden
# while the rail carried its children), so all eight stay reachable.
self.nav = self._new_nav_tree("navrail")
self.nav_bottom = self._new_nav_tree("navrailBottom")
self._nav_building = False # guards the rebuild → select → rebuild loop
self.workspace.hide_tab_bar()
self._rebuild_nav()
self.workspace.subtabs_changed.connect(self._rebuild_nav)
for tree in (self.nav, self.nav_bottom):
tree.currentItemChanged.connect(
lambda cur, _prev, t=tree: self._on_nav_current(t, cur))
rlay.addWidget(self.pages, 1)
# Nav rail wrapper: a small toggle button ABOVE the page list so the
# whole rail can collapse to icon-only (still fully clickable). Same
# collapse/expand chevron iconography as every other collapsible panel.
from ...ui.icons import collapse_left_icon, collapse_right_icon
from ...ui.icons import icon as _icon
self._collapse_left_icon = collapse_left_icon
self._collapse_right_icon = collapse_right_icon
self._nav_wrap = QWidget()
self._nav_wrap.setObjectName("navWrap")
self._nav_width = _NAV_EXPANDED_WIDTH # remembered across collapses
self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width())
nvl = QVBoxLayout(self._nav_wrap)
nvl.setContentsMargins(0, 0, 0, 0)
nvl.setSpacing(0)
# Small, left-aligned "MENU" button (icon + label) instead of a
# full-width centered icon — sits flush with the rail's left edge,
# matching how the nav items themselves align their icon+label.
self._nav_toggle_btn = QPushButton(tr("app.nav.menu_label"))
self._nav_toggle_btn.setIcon(collapse_left_icon())
self._nav_toggle_btn.setObjectName("navMenuBtn")
self._nav_toggle_btn.setFlat(True)
self._nav_toggle_btn.setCursor(Qt.PointingHandCursor)
self._nav_toggle_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self._nav_toggle_btn.clicked.connect(self._toggle_nav)
# Zero left margin: the button's own QSS padding (6px) then lines its
# 16px icon up with the nav items' icons below (1px list frame + item
# padding) — same indent level, same icon size as e.g. Dashboard.
toggle_row = QHBoxLayout()
toggle_row.setContentsMargins(10, 8, 10, 8)
toggle_row.addWidget(self._nav_toggle_btn, 0, Qt.AlignLeft)
toggle_row.addStretch(1)
nvl.addLayout(toggle_row)
# Primary action at the top of the rail, with the project it will land
# in named right above it. Before, starting a chat in another project
# meant leaving Cowork → Project tab → click a row → come back.
self.nav_project = QComboBox()
self.nav_project.setObjectName("navProjectPick")
self.nav_project.setToolTip(tr("app.nav.project_pick"))
self.nav_project.currentIndexChanged.connect(self._on_rail_project_pick)
tidy_popup(self.nav_project)
self.nav_new_chat = QPushButton(tr("cowork.new_chat"))
self.nav_new_chat.setObjectName("navNewChatBtn")
self.nav_new_chat.setIcon(_icon("plus"))
self.nav_new_chat.setCursor(Qt.PointingHandCursor)
self.nav_new_chat.clicked.connect(self._on_rail_new_chat)
# At 54px the picker cannot show a name, but dropping it altogether left
# the collapsed rail with no way to change project at all. This stands in
# for it: same list, same handler, just the folder icon and a tooltip.
self.nav_project_btn = QToolButton()
self.nav_project_btn.setObjectName("navProjectPickMini")
self.nav_project_btn.setIcon(_icon("folder"))
self.nav_project_btn.setCursor(Qt.PointingHandCursor)
self.nav_project_btn.setPopupMode(QToolButton.InstantPopup)
self.nav_project_btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.nav_project_btn.setMenu(QMenu(self.nav_project_btn))
self.nav_project_btn.menu().aboutToShow.connect(self._fill_rail_project_menu)
self.nav_project_btn.setVisible(False)
head = QVBoxLayout()
head.setContentsMargins(10, 0, 10, 6)
head.setSpacing(6)
head.addWidget(self.nav_project)
head.addWidget(self.nav_project_btn)
head.addWidget(self.nav_new_chat)
nvl.addLayout(head)
self.workspace.project_selected.connect(self._sync_rail_project)
self.workspace.projects_changed.connect(self._sync_rail_project)
self._syncing_rail_project = False
self._sync_rail_project()
# The destinations and RECENTS scroll together; the bottom group, the
# Settings button and the account row stay pinned below them.
#
# Without this the rail simply ran out of room on a short window (a
# 1280×720 laptop leaves ~570px here): nav and the bottom group have
# fixed heights, so the squeeze fell entirely on RECENTS, and once that
# hit zero the layout drew the "GẦN ĐÂY" heading straight over the last
# nav row.
self._nav_scroll = QScrollArea()
self._nav_scroll.setObjectName("navScroll")
self._nav_scroll.setWidgetResizable(True)
self._nav_scroll.setFrameShape(QScrollArea.NoFrame)
self._nav_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll_body = QWidget()
sv = QVBoxLayout(scroll_body)
sv.setContentsMargins(6, 0, 6, 0)
sv.setSpacing(0)
sv.addWidget(self.nav, 0)
# RECENTS — the threads of the project named in the picker above, right
# where Claude puts them. A shortcut only: the full History panel (search,
# filters, pin, bulk delete, context menu) stays exactly where it is, and
# "all projects…" at the end of this list opens it.
self.nav_recents_hdr = QLabel(tr("app.nav.recents"))
self.nav_recents_hdr.setObjectName("navSectionHdr")
sv.addWidget(self.nav_recents_hdr)
self.nav_recents = self._new_nav_tree("navRecents")
self.nav_recents.itemClicked.connect(self._on_rail_recent)
sv.addWidget(self.nav_recents, 1)
# Collapsing hides RECENTS, and with it the only item carrying a stretch
# factor. A box layout with nothing left to expand centres what remains,
# so the destinations dropped ~300px down the rail — "thu gọn menu lại
# ra giữa". This spacer takes the slack instead, and takes none of it
# while RECENTS is visible (stretch 0 against its 1).
sv.addStretch(0)
self._nav_scroll.setWidget(scroll_body)
nvl.addWidget(self._nav_scroll, 1)
self._build_rail_bottom(nvl)
nvl.addWidget(self._account_row)
self.split = QSplitter(Qt.Horizontal)
self.split.addWidget(self._nav_wrap)
self.split.addWidget(right)
self.split.setStretchFactor(0, 0)
self.split.setStretchFactor(1, 1)
self.split.setSizes([_NAV_EXPANDED_WIDTH, 1000])
self.split.splitterMoved.connect(self._on_split_moved)
self.setCentralWidget(self.split)
def _new_nav_tree(self, name: str) -> QTreeWidget:
"""One flat, single-column list. No indentation and no expand arrows —
every row is a destination, nothing is a container."""
tree = QTreeWidget()
tree.setObjectName(name)
tree.setHeaderHidden(True)
tree.setIndentation(0)
tree.setRootIsDecorated(False)
tree.setUniformRowHeights(True)
# The column follows the viewport instead of the widest label. Left
# to size itself it stayed ~100px wide inside the 54px collapsed
# rail, so a horizontal scrollbar appeared and slid the icons out of
# the position they hold while the rail is open.
from PySide6.QtWidgets import QHeaderView
tree.header().setSectionResizeMode(0, QHeaderView.Stretch)
tree.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
tree.setItemDelegate(_NavItemDelegate(tree))
return tree
def _nav_rows(self):
"""(tree, page, sub, label, icon, enabled) for every row, rail order.
Workspace contributes all five of its sub-views — including the two the
project gate currently disables — so the rail never changes shape while
the user is looking at it.
"""
rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on)
for label, sub, ic, on in self.workspace.nav_entries()]
rows.append((self.nav, self._ROW_SCHEDULE, None,
tr("app.tab.schedule"), "schedule", True))
rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,
tr("app.tab.dashboard"), "dashboard", True))
rows.append((self.nav_bottom, self._ROW_MONITORING, None,
tr("app.tab.monitoring"), "monitoring", True))
return rows
def _rebuild_nav(self, force: bool = False) -> None:
"""Re-fill both lists from _nav_rows(), keeping the current selection.
Rebuilding changes the current item, which would fire navigation and can
loop back here via subtabs_changed — hence the guard and the blocked
signals.
"""
if self._nav_building:
return
spec = self._nav_rows()
# Rebuilding deletes the QTreeWidgetItems, including the one a signal is
# currently being delivered for. subtabs_changed fires on every visit to
# Workspace, so skip the rebuild unless the rows really differ.
sig = [(label, page, sub, enabled)
for _t, page, sub, label, _ic, enabled in spec]
if not force and sig == getattr(self, "_nav_sig", None):
return
self._nav_sig = sig
self._nav_building = True
try:
from ...ui.icons import icon as _icon
keep = self._current_nav_key()
for tree in (self.nav, self.nav_bottom):
blocked = tree.blockSignals(True)
tree.clear()
tree.blockSignals(blocked)
for tree, page, sub, label, icon_name, enabled in spec:
it = QTreeWidgetItem([""] if self._nav_collapsed else [label])
it.setIcon(0, _icon(icon_name))
it.setData(0, Qt.UserRole, {"page": page, "sub": sub})
if not enabled:
# Same gate as before, shown instead of hidden: the row stays
# in place, greyed, and says why it cannot be opened.
it.setDisabled(True)
it.setToolTip(0, tr("app.nav.needs_project"))
elif self._nav_collapsed:
it.setToolTip(0, label)
blocked = tree.blockSignals(True)
tree.addTopLevelItem(it)
tree.blockSignals(blocked)
# Both destination lists are exactly as tall as their rows; the
# stretch in between belongs to RECENTS.
for tree in (self.nav, self.nav_bottom):
n = tree.topLevelItemCount()
row_h = tree.sizeHintForRow(0) if n else 0
tree.setFixedHeight(n * row_h + 8)
if keep:
self._select_nav_row(*keep)
finally:
self._nav_building = False
def _current_nav_key(self):
"""(page, sub) of the highlighted row, or None."""
for tree in (self.nav, self.nav_bottom):
it = tree.currentItem()
if it is not None and it.isSelected():
data = it.data(0, Qt.UserRole) or {}
if "page" in data:
return data["page"], data.get("sub")
return None
def _select_nav_row(self, page: int, sub) -> None:
"""Highlight the row for (page, sub) without triggering navigation.
Called both when the user clicks (to keep the two lists mutually
exclusive) and from _goto, so programmatic navigation moves the
highlight too — it used to stay behind on whatever was clicked last.
"""
for tree in (self.nav, self.nav_bottom):
blocked = tree.blockSignals(True)
match = None
for i in range(tree.topLevelItemCount()):
it = tree.topLevelItem(i)
data = it.data(0, Qt.UserRole) or {}
if data.get("page") == page and (
data.get("sub") == sub or data.get("sub") is None):
match = it
break
if match is not None:
tree.setCurrentItem(match)
else:
tree.setCurrentItem(None)
tree.clearSelection()
tree.blockSignals(blocked)
def _on_nav_current(self, tree: QTreeWidget, item) -> None:
"""A row was picked: clear the other list so only one row looks active."""
if item is None or self._nav_building:
return
data = item.data(0, Qt.UserRole) or {}
other = self.nav_bottom if tree is self.nav else self.nav
blocked = other.blockSignals(True)
other.setCurrentItem(None)
other.clearSelection()
other.blockSignals(blocked)
self._goto(data.get("page", 0), data.get("sub"))
# ---- rail header: project picker + new chat ------------------------
# ---- rail RECENTS --------------------------------------------------
_RAIL_RECENTS = 5
def _apply_nav_labels(self) -> None:
"""Re-label every row for the current language and collapse state
(collapsed = icon only, label moves to the tooltip)."""
# force: collapsing leaves the row spec identical, only the text changes.
self._rebuild_nav(force=True)
self._nav_settings_text.setText(tr("app.settings"))
self._nav_settings_text.setVisible(not self._nav_collapsed)
self._nav_settings_btn.setToolTip(tr("app.settings"))
# Collapsed to 54px there is no room for either control's label; the
# picker would be a stub of a name, so it steps aside entirely and the
# button keeps just its + icon.
self.nav_project.setVisible(not self._nav_collapsed)
self.nav_project_btn.setVisible(self._nav_collapsed)
self._refresh_rail_recents()
# Collapsed to 54px only the theme toggle still fits; the rest of the
# account row would be clipped, so it steps aside (Settings, which opens
# the same values in a dialog, stays reachable as an icon).
self.account_lbl.setVisible(not self._nav_collapsed)
self.language_combo.setVisible(not self._nav_collapsed)
self.provider_combo.setVisible(not self._nav_collapsed)
self.nav_new_chat.setText("" if self._nav_collapsed else tr("cowork.new_chat"))
if self._nav_new_chat_enabled():
self.nav_new_chat.setToolTip(
tr("cowork.new_chat") if self._nav_collapsed else "")
self._sync_rail_project()
def _nav_max_width(self) -> int:
"""The rail's ceiling for THIS window, as a share of it."""
return max(_NAV_MIN_WIDTH,
min(_NAV_MAX_CEILING, int(self.width() * _NAV_MAX_SHARE)))
def _set_nav_width_range(self, lo: int, hi: int) -> None:
"""setFixedWidth would leave the splitter handle inert — visible, and
doing nothing when dragged."""
self._nav_wrap.setMinimumWidth(lo)
self._nav_wrap.setMaximumWidth(hi)
def _on_split_moved(self, _pos: int, _index: int) -> None:
"""Người dùng kéo thanh chia: nhớ lại bề rộng thanh menu để lần mở sau giữ nguyên."""
if not self._nav_collapsed:
self._nav_width = max(_NAV_MIN_WIDTH,
min(self._nav_max_width(), self._nav_wrap.width()))
def _toggle_nav(self) -> None:
"""Thu gọn / mở rộng thanh menu, nhớ bề rộng trước khi thu để khôi phục đúng."""
if not self._nav_collapsed:
self._nav_width = max(_NAV_MIN_WIDTH,
min(self._nav_max_width(), self._nav_wrap.width()))
self._nav_collapsed = not self._nav_collapsed
if self._nav_collapsed:
width = _NAV_COLLAPSED_WIDTH
self._set_nav_width_range(width, width)
else:
width = self._nav_width
self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width())
self._apply_nav_labels()
# Same chevron convention as every other collapsible panel: right-
# pointing (fill-right) means "click to expand", left means "collapse".
self._nav_toggle_btn.setIcon(
self._collapse_right_icon() if self._nav_collapsed else self._collapse_left_icon())
# Collapsed rail is icon-only (54px) — the "MENU" label wouldn't fit
# next to the icon, same rule the nav items themselves follow.
self._nav_toggle_btn.setText("" if self._nav_collapsed else tr("app.nav.menu_label"))
self._nav_toggle_btn.setToolTip(
tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip"))
# Give/reclaim the width difference to the main content pane.
sizes = self.split.sizes()
if len(sizes) == 2:
diff = sizes[0] - width
sizes[0] = width
sizes[1] = max(1, sizes[1] + diff)
self.split.setSizes(sizes)