CI / test (push) Canceled after 0s
## 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. Reviewed-on: #11 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
1021 lines
48 KiB
Python
1021 lines
48 KiB
Python
"""Workspace screen — the app's home. Manages Projects (Claude-Projects style)
|
|
AND hosts, per selected project, the **Cowork** chat and **GraphRAG** views as
|
|
sub-tabs, all confined to that project's sandbox.
|
|
|
|
Left: the list of projects (create / delete, collapsible). Right: a tab strip
|
|
for the selected project —
|
|
|
|
* **Cowork** — the chat (with its per-project History sidebar).
|
|
* **GraphRAG** — the knowledge graph, locked to the project's sandbox.
|
|
* **Project** — name, description, shared **instructions** (injected into every
|
|
chat of the project), its sandbox **workspace folder**, and the project's
|
|
conversation **threads**.
|
|
|
|
The Cowork/GraphRAG widgets are created by the main window and handed in so the
|
|
whole app shares one instance of each; when they are not provided (e.g. unit
|
|
tests that exercise only project management) the screen still works as a plain
|
|
project manager.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from PySide6.QtCore import Qt, Signal
|
|
from PySide6.QtWidgets import (
|
|
QFileDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
|
|
QPlainTextEdit, QPushButton, QSplitter, QTabWidget,
|
|
QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,
|
|
)
|
|
|
|
from ..i18n import on_language_changed, tr
|
|
from ..presentation.workspace.project_editing import ProjectEditingMixin, ProjectRow
|
|
from ..state import AppContext
|
|
from .icons import collapse_left_icon, icon
|
|
from .osutil import open_folder
|
|
from .widgets import CollapseStrip
|
|
|
|
|
|
class WorkspaceTab(ProjectEditingMixin, QWidget):
|
|
"""Trang chủ Workspace: cột project, cột lịch sử, và 5 sub-tab
|
|
(Dự án · Cowork · Co4E · Thư mục · GraphRAG).
|
|
|
|
Đây là chỗ CHỐT project đang hoạt động: :meth:`_bind_project` đặt
|
|
``ctx.active_project_id``, và mọi chế độ theo-workspace (định tuyến, tự
|
|
chạy) đều phân giải theo giá trị đó.
|
|
"""
|
|
status_message = Signal(str)
|
|
open_chat = Signal(str, dict) # kind, conversation — open a thread in Cowork
|
|
new_chat = Signal(str) # project_id — start a new thread in this project
|
|
projects_changed = Signal() # created/edited/deleted → History regroups
|
|
subtabs_changed = Signal() # visible sub-tabs changed → left-nav children refresh
|
|
project_selected = Signal(str) # project_id — the rail's picker follows this
|
|
|
|
# ---- nav integration: the sub-tabs are driven from the left nav rail -----
|
|
def nav_subtabs(self):
|
|
"""(label, index, icon_name) for each VISIBLE sub-tab — the left nav lists
|
|
these as children under 'Workspaces'. Icons are keyed by tab index (not
|
|
label) so they're correct in every language."""
|
|
icons = {self._project_tab_idx: "folder", self._cowork_tab_idx: "chat",
|
|
self._co4e_tab_idx: "flow", self._folder_tab_idx: "folder",
|
|
self._graphrag_tab_idx: "graph"}
|
|
return [(self.tabs.tabText(i), i, icons.get(i, "chevron-right"))
|
|
for i in range(self.tabs.count()) if self.tabs.isTabVisible(i)]
|
|
|
|
def nav_entries(self):
|
|
"""(label, index, icon_name, enabled) for EVERY sub-tab, hidden ones
|
|
included.
|
|
|
|
Cột ``enabled`` là trạng thái cổng project; ``NavRailMixin._rebuild_nav``
|
|
bỏ hẳn những hàng đang đóng (Cowork, GraphRAG) khỏi menu trái cho tới khi
|
|
người dùng chọn một project. See nav_subtabs() for the visible-only view.
|
|
"""
|
|
icons = {self._project_tab_idx: "folder", self._cowork_tab_idx: "chat",
|
|
self._co4e_tab_idx: "flow", self._folder_tab_idx: "folder",
|
|
self._graphrag_tab_idx: "graph"}
|
|
return [(self.tabs.tabText(i), i, icons.get(i, "chevron-right"),
|
|
self.tabs.isTabVisible(i))
|
|
for i in range(self.tabs.count())]
|
|
|
|
def subtab_available(self, index: int) -> bool:
|
|
"""False while the project gate is holding this sub-tab shut.
|
|
|
|
Rail bỏ hẳn những hàng đó khỏi menu, nhưng đó chỉ chắn được đường vào
|
|
qua rail. Hàm này để mọi đường vào khác hỏi cùng một trạng thái.
|
|
"""
|
|
return bool(0 <= index < self.tabs.count() and self.tabs.isTabVisible(index))
|
|
|
|
def select_subtab(self, index: int) -> None:
|
|
"""Chuyển sang sub-tab thứ ``index`` (thanh menu bên trái gọi vào đây)."""
|
|
if 0 <= index < self.tabs.count():
|
|
self.tabs.setCurrentIndex(index)
|
|
|
|
def current_subtab(self) -> int:
|
|
"""Chỉ số sub-tab đang mở."""
|
|
return self.tabs.currentIndex()
|
|
|
|
def hide_tab_bar(self) -> None:
|
|
"""Hide the in-content tab strip (the nav rail drives the sub-tabs now),
|
|
so the content area is as large as possible."""
|
|
self.tabs.tabBar().hide()
|
|
|
|
def __init__(self, ctx: AppContext, cowork=None, structure=None, sidebar=None):
|
|
"""Màn Workspace: danh sách dự án bên trái, các tab con của dự án bên phải.
|
|
|
|
Cột lịch sử mở ở trạng thái gập cho tới khi người dùng chủ động mở ra, đúng
|
|
như bản vẽ bố cục màn này.
|
|
"""
|
|
super().__init__()
|
|
self.ctx = ctx
|
|
self._current_id = ""
|
|
# True once the user asks for the full History panel; until then Cowork
|
|
# opens with it folded, as the drawing lays the screen out.
|
|
self._history_opened = False
|
|
# Shared widgets embedded as per-project sub-tabs (None in unit tests
|
|
# that only drive project management).
|
|
self._cowork = cowork
|
|
self._structure = structure
|
|
self._sidebar = sidebar
|
|
|
|
root = QVBoxLayout(self)
|
|
# Title row — the drawing puts "+ Project mới" up here beside the title,
|
|
# not at the foot of the project list where it read as belonging to the
|
|
# list's own controls.
|
|
self._header = QLabel()
|
|
self._header.setStyleSheet("font-weight:700; font-size:15px;")
|
|
self._new_btn = QPushButton()
|
|
self._new_btn.setIcon(icon("plus"))
|
|
self._new_btn.setObjectName("primary")
|
|
self._new_btn.clicked.connect(self._create)
|
|
title_row = QHBoxLayout()
|
|
title_row.addWidget(self._header)
|
|
title_row.addStretch(1)
|
|
title_row.addWidget(self._new_btn)
|
|
root.addLayout(title_row)
|
|
self._hint = QLabel()
|
|
self._hint.setObjectName("hint")
|
|
self._hint.setWordWrap(True)
|
|
root.addWidget(self._hint)
|
|
|
|
self._split = QSplitter(Qt.Horizontal)
|
|
root.addWidget(self._split, 1)
|
|
|
|
# ---- left: project list (collapsible — same chevron/strip pattern
|
|
# as History and the GraphRAG Agent panel) ---------------------------
|
|
left = QWidget()
|
|
ll = QVBoxLayout(left)
|
|
ll.setContentsMargins(0, 0, 0, 0)
|
|
left_hdr = QHBoxLayout()
|
|
self._projects_hdr = QLabel()
|
|
self._projects_hdr.setObjectName("navSectionHdr")
|
|
left_hdr.addWidget(self._projects_hdr)
|
|
self._proj_collapse_btn = QPushButton()
|
|
self._proj_collapse_btn.setIcon(collapse_left_icon())
|
|
self._proj_collapse_btn.setFixedWidth(28)
|
|
self._proj_collapse_btn.clicked.connect(lambda: self._set_projects_collapsed(True))
|
|
left_hdr.addWidget(self._proj_collapse_btn)
|
|
left_hdr.addStretch(1)
|
|
ll.addLayout(left_hdr)
|
|
self.project_list = QListWidget()
|
|
self.project_list.currentItemChanged.connect(self._on_select)
|
|
ll.addWidget(self.project_list, 1)
|
|
btns = QHBoxLayout()
|
|
# Delete stays under the list it acts on. The drawing does not show it,
|
|
# but it does not show it moved either, and dropping a control is not
|
|
# something a layout pass gets to do.
|
|
self._del_btn = QPushButton()
|
|
self._del_btn.setIcon(icon("trash"))
|
|
self._del_btn.clicked.connect(self._delete)
|
|
btns.addWidget(self._del_btn, 1)
|
|
ll.addLayout(btns)
|
|
self._projects_panel = left
|
|
|
|
# Thin strip shown in place of the project list when collapsed —
|
|
# clicking it re-expands (identical affordance to History's).
|
|
self._projects_strip = CollapseStrip(tr("workspace.expand_projects_tooltip"), expand_dir="right")
|
|
self._projects_strip.clicked.connect(lambda: self._set_projects_collapsed(False))
|
|
self._projects_strip.setVisible(False)
|
|
self._projects_pane = QWidget()
|
|
ppl = QHBoxLayout(self._projects_pane)
|
|
ppl.setContentsMargins(0, 0, 0, 0)
|
|
ppl.setSpacing(0)
|
|
ppl.addWidget(self._projects_strip)
|
|
ppl.addWidget(left, 1)
|
|
self._split.addWidget(self._projects_pane)
|
|
|
|
# ---- right: per-project tabs (Project / Cowork / GraphRAG) -----------
|
|
# Project comes FIRST; Cowork + GraphRAG only appear once a project is
|
|
# actually selected (see _update_tab_visibility).
|
|
self.tabs = QTabWidget()
|
|
# A QTabWidget's minimum width is the MAXIMUM over every page, hidden
|
|
# ones included — so Co4E (the widest, ~1180px) was setting the floor for
|
|
# Project and Cowork as well, and through them for the whole window,
|
|
# which then refused to be smaller than 1453px on any screen. An explicit
|
|
# minimum overrides that: each page still gets whatever width is going,
|
|
# and the pages that are not on screen no longer vote.
|
|
self.tabs.setMinimumWidth(560)
|
|
self._project_tab_idx = self.tabs.addTab(self._build_project_tab(), tr("workspace.tab_project"))
|
|
self._cowork_tab_idx = -1
|
|
self._graphrag_tab_idx = -1
|
|
|
|
if self._cowork is not None:
|
|
cowork_page = QWidget()
|
|
cpl = QVBoxLayout(cowork_page)
|
|
cpl.setContentsMargins(0, 0, 0, 0)
|
|
cpl.addWidget(self._cowork)
|
|
self._cowork_tab_idx = self.tabs.addTab(cowork_page, tr("workspace.tab_cowork"))
|
|
|
|
# Co4E — node-graph workflow studio (built-in flows, agents, skills, a
|
|
# runner + chat). Always available (not project-gated): its workflows
|
|
# live globally under ~/.cowork_local/co4e, not inside one project.
|
|
# Placed BEFORE GraphRAG in the tab order (user request).
|
|
from .co4e_tab import Co4ETab
|
|
|
|
self._co4e = Co4ETab(self.ctx)
|
|
self._co4e_tab_idx = self.tabs.addTab(self._co4e, tr("workspace.tab_co4e"))
|
|
self.tabs.setTabToolTip(self._co4e_tab_idx, tr("workspace.tab_co4e_tooltip"))
|
|
|
|
# Folder — a two-pane file explorer (tree + view/edit) placed right below
|
|
# Co4E. Always available (not project-gated); its root follows the
|
|
# selected project's workspace folder when one is chosen.
|
|
from ..presentation.folder.folder_tab import FolderTab
|
|
|
|
self._folder = FolderTab(self.ctx, cowork=self._cowork)
|
|
self._folder.status_message.connect(self.status_message)
|
|
self._folder_tab_idx = self.tabs.addTab(self._folder, tr("workspace.tab_folder"))
|
|
|
|
if self._structure is not None:
|
|
self._graphrag_tab_idx = self.tabs.addTab(self._structure, tr("workspace.tab_graphrag"))
|
|
|
|
self.tabs.currentChanged.connect(self._on_tab_changed)
|
|
|
|
# History lives in its own pane of the OUTER splitter (not nested
|
|
# inside the Cowork tab page) so it stays visible across Cowork AND
|
|
# GraphRAG, instead of disappearing whenever GraphRAG is the active
|
|
# sub-tab (QTabWidget only shows the current page's widget).
|
|
if self._sidebar is not None:
|
|
self._split.addWidget(self._sidebar)
|
|
self._wire_sidebar()
|
|
self._split.addWidget(self.tabs)
|
|
self._split.setStretchFactor(0, 0)
|
|
if self._sidebar is not None:
|
|
self._split.setStretchFactor(1, 0)
|
|
self._split.setStretchFactor(2, 1)
|
|
self._split.setSizes([260, 240, 800])
|
|
else:
|
|
self._split.setStretchFactor(1, 1)
|
|
self._split.setSizes([260, 900])
|
|
self._apply_pane_visibility()
|
|
|
|
self.refresh()
|
|
self.install_project_editing()
|
|
on_language_changed(self._retranslate)
|
|
self._retranslate()
|
|
|
|
# ---- project settings tab -------------------------------------------
|
|
def _build_project_tab(self) -> QWidget:
|
|
"""Dựng sub-tab "Dự án": tên, mô tả, chỉ dẫn chung và thư mục sandbox."""
|
|
right = QWidget()
|
|
rl = QVBoxLayout(right)
|
|
rl.setContentsMargins(8, 4, 4, 4)
|
|
|
|
self.name_edit = QLineEdit()
|
|
self.desc_edit = QLineEdit()
|
|
self._name_lbl = QLabel()
|
|
self._desc_lbl = QLabel()
|
|
rl.addWidget(self._name_lbl)
|
|
rl.addWidget(self.name_edit)
|
|
rl.addWidget(self._desc_lbl)
|
|
rl.addWidget(self.desc_edit)
|
|
|
|
self._instr_lbl = QLabel()
|
|
self.instr_edit = QPlainTextEdit()
|
|
self.instr_edit.setMaximumHeight(120)
|
|
rl.addWidget(self._instr_lbl)
|
|
rl.addWidget(self.instr_edit)
|
|
|
|
self._folder_hdr = QLabel()
|
|
rl.addWidget(self._folder_hdr)
|
|
folder_row = QHBoxLayout()
|
|
# The drawing shows the path in a field, not as grey caption text. A
|
|
# read-only line edit looks like one and, unlike a label, lets the path
|
|
# be selected and copied.
|
|
self.folder_lbl = QLineEdit()
|
|
self.folder_lbl.setReadOnly(True)
|
|
self._browse_btn = QPushButton()
|
|
self._browse_btn.setIcon(icon("folder"))
|
|
self._browse_btn.clicked.connect(self._pick_folder)
|
|
self._open_btn = QPushButton()
|
|
self._open_btn.setIcon(icon("upload"))
|
|
self._open_btn.clicked.connect(self._open_workspace)
|
|
folder_row.addWidget(self.folder_lbl, 1)
|
|
folder_row.addWidget(self._browse_btn)
|
|
folder_row.addWidget(self._open_btn)
|
|
rl.addLayout(folder_row)
|
|
|
|
# DF-007 — an ALTERNATIVE way to set output_dir: browse OneDrive/
|
|
# SharePoint via Graph API and download a local mirror instead of
|
|
# picking an already-local folder. output_dir still always ends up a
|
|
# real local path (see Project.cloud_source) — nothing downstream
|
|
# (run_command/read_file/...) needs to know the difference.
|
|
cloud_row = QHBoxLayout()
|
|
self._cloud_pick_btn = QPushButton()
|
|
self._cloud_pick_btn.setIcon(icon("cloud"))
|
|
self._cloud_pick_btn.clicked.connect(self._pick_cloud_folder)
|
|
self._cloud_sync_btn = QPushButton()
|
|
self._cloud_sync_btn.setIcon(icon("refresh"))
|
|
self._cloud_sync_btn.clicked.connect(self._sync_cloud_folder)
|
|
self._cloud_sync_btn.hide()
|
|
cloud_row.addWidget(self._cloud_pick_btn)
|
|
cloud_row.addWidget(self._cloud_sync_btn)
|
|
cloud_row.addStretch(1)
|
|
rl.addLayout(cloud_row)
|
|
self._cloud_badge_lbl = QLabel()
|
|
self._cloud_badge_lbl.setWordWrap(True)
|
|
self._cloud_badge_lbl.hide()
|
|
rl.addWidget(self._cloud_badge_lbl)
|
|
|
|
rl.addStretch(1) # the drawing floats Lưu project at the bottom
|
|
save_row = QHBoxLayout()
|
|
self._save_btn = QPushButton()
|
|
self._save_btn.setIcon(icon("save"))
|
|
self._save_btn.setObjectName("primary")
|
|
self._save_btn.clicked.connect(self._save)
|
|
save_row.addStretch(1)
|
|
save_row.addWidget(self._save_btn)
|
|
rl.addLayout(save_row)
|
|
|
|
# The per-project conversation-threads list ("group chat") was removed
|
|
# from here — chats live in the History sidebar + the Cowork tab. Keep
|
|
# the settings compact at the top with a stretch below.
|
|
rl.addStretch(1)
|
|
return right
|
|
|
|
# ---- embedded sidebar (History inside the Cowork tab) ---------------
|
|
def _wire_sidebar(self) -> None:
|
|
"""Nối các tín hiệu của cột lịch sử vào màn Workspace."""
|
|
sb = self._sidebar
|
|
sb.open_chat.connect(self._on_sidebar_open)
|
|
sb.new_chat.connect(self._on_sidebar_new)
|
|
sb.collapse_requested.connect(lambda: self._on_history_fold(True))
|
|
sb.expand_requested.connect(lambda: self._on_history_fold(False))
|
|
sb.refresh_requested.connect(self._on_sidebar_refresh)
|
|
sb.history_changed.connect(self._reload_threads)
|
|
|
|
def _on_history_fold(self, collapsed: bool) -> None:
|
|
"""Its chevron closes the panel away, back to the drawn layout."""
|
|
self._history_opened = not collapsed
|
|
if collapsed:
|
|
self._sidebar.setVisible(False)
|
|
self._apply_pane_visibility()
|
|
else:
|
|
self._set_sidebar_collapsed(False)
|
|
|
|
def _set_sidebar_collapsed(self, collapsed: bool) -> None:
|
|
"""Collapse/expand History sidebar and redistribute splitter space so
|
|
the Cowork chat area fills the freed width (same pattern as
|
|
_set_projects_collapsed)."""
|
|
sb = self._sidebar
|
|
sb.set_collapsed(collapsed)
|
|
strip_w = CollapseStrip.WIDTH + 2
|
|
if sb is None:
|
|
return
|
|
# Find sidebar index in splitter
|
|
idx = self._split.indexOf(sb)
|
|
if not (0 <= idx < self._split.count()):
|
|
return
|
|
sizes = self._split.sizes()
|
|
if collapsed:
|
|
freed = sizes[idx] - strip_w
|
|
sizes[idx] = strip_w
|
|
else:
|
|
freed = 240 - sizes[idx]
|
|
sizes[idx] = 240
|
|
# Give freed width to the LAST pane (tabs/Cowork area)
|
|
if freed != 0 and len(sizes) > 1:
|
|
sizes[-1] = max(1, sizes[-1] + freed)
|
|
self._split.setSizes(sizes)
|
|
|
|
def _on_sidebar_open(self, kind: str, conv: dict) -> None:
|
|
"""Mở một hội thoại từ cột lịch sử, kèm chuyển sang đúng project của nó.
|
|
|
|
Id "default" (hội thoại cũ chưa gắn project) CỐ Ý không được coi là một
|
|
project thật — Cowork xử lý riêng nó như phạm vi toàn cục, không có tri thức
|
|
project nào.
|
|
"""
|
|
pid = conv.get("project_id", "") or "default"
|
|
# The legacy "default"/no-project id is intentionally not a real
|
|
# Project row (Cowork itself special-cases it as global/no-knowledge —
|
|
# see chat_agent.py), so it's fine to open with nothing selected. But a
|
|
# genuinely deleted project id must NOT force the Cowork tab open,
|
|
# since _update_tab_visibility never ran for it — that would show the
|
|
# Cowork page while the tab strip still says "no project selected".
|
|
if pid not in ("", "default") and not self._select_project_row(pid):
|
|
self.status_message.emit(tr("workspace.conversation_project_missing"))
|
|
return
|
|
if self._cowork is not None:
|
|
self._cowork.load_conversation(conv)
|
|
self._show_cowork_tab()
|
|
self.open_chat.emit(kind or "cowork", conv)
|
|
|
|
def _on_sidebar_new(self, kind: str) -> None:
|
|
"""Bấm "chat mới" ở cột lịch sử: mở hội thoại mới rồi nhảy sang tab Cowork."""
|
|
if self._cowork is not None:
|
|
self._cowork.new_session()
|
|
self._show_cowork_tab()
|
|
|
|
def _on_sidebar_refresh(self) -> None:
|
|
"""Cột lịch sử yêu cầu làm mới: cập nhật lại trạng thái Cowork và danh sách."""
|
|
if self._cowork is not None:
|
|
self._cowork.refresh_status()
|
|
if self._sidebar is not None:
|
|
self._sidebar.refresh()
|
|
|
|
def _show_cowork_tab(self) -> None:
|
|
"""Chuyển sang sub-tab Cowork nếu nó đang hiện."""
|
|
if self._cowork_tab_idx >= 0:
|
|
self.tabs.setCurrentIndex(self._cowork_tab_idx)
|
|
|
|
def _on_tab_changed(self, idx: int) -> None:
|
|
# Entering GraphRAG builds its (lazy) WebEngine view and scans the
|
|
# project's sandbox; entering it is what keeps startup RAM low.
|
|
"""Đổi sub-tab: vào GraphRAG mới dựng khung WebEngine và quét sandbox.
|
|
|
|
Dựng lười như vậy chính là thứ giữ cho RAM lúc khởi động ở mức thấp.
|
|
"""
|
|
if idx == self._graphrag_tab_idx and self._structure is not None:
|
|
self._structure.auto_scan_and_fit()
|
|
self._apply_pane_visibility()
|
|
|
|
def _apply_pane_visibility(self) -> None:
|
|
"""Which side panes accompany each sub-tab:
|
|
|
|
Project → project list ✓ History ✗
|
|
Cowork → project list ✗ History ✓
|
|
GraphRAG → project list ✗ History ✗
|
|
|
|
Every page other than Project/Cowork (GraphRAG) gets the full width;
|
|
switching projects is done from the Project tab (the list there is
|
|
the app's only project picker)."""
|
|
idx = self.tabs.currentIndex()
|
|
on_project = idx == self._project_tab_idx
|
|
on_cowork = self._cowork_tab_idx >= 0 and idx == self._cowork_tab_idx
|
|
self._projects_pane.setVisible(on_project)
|
|
# "Workspace — Projects" and its three-line explanation describe the
|
|
# PROJECT screen, but were drawn above every sub-tab — ~90px of vertical
|
|
# space taken from Co4E's canvas and Folder's tree on every laptop
|
|
# screen. Shown where they apply; the text itself is unchanged.
|
|
self._header.setVisible(on_project)
|
|
# The title row sits above the sub-tabs, so everything on it has to
|
|
# follow the same rule the title does — moving + Project mới up here put
|
|
# it in the corner of Cowork, Co4E, Folder and GraphRAG as well.
|
|
self._new_btn.setVisible(on_project)
|
|
# ...and the explanation only while there is nothing to explain against:
|
|
# the drawing heads a populated screen with the title alone.
|
|
self._hint.setVisible(on_project and self.project_list.count() == 0)
|
|
narrow = getattr(self, "_is_narrow", False)
|
|
if self._sidebar is not None:
|
|
# Cowork is two columns in the drawing — transcript and files — with
|
|
# no history pane and no strip where one used to be. The relocation
|
|
# table is explicit: History moves to Sidebar ▸ RECENTS, "giữ, dễ
|
|
# tới hơn". So the panel is not on this screen at all until asked
|
|
# for: "Tất cả project…" in RECENTS brings it in, since search,
|
|
# filters, pin, rename and multi-select delete live only there.
|
|
self._sidebar.setVisible(on_cowork and self._history_opened)
|
|
if on_cowork and self._history_opened:
|
|
self._sidebar.set_collapsed(False)
|
|
# QSplitter ignores hidden panes, but the freed width isn't handed to
|
|
# the remaining panes deterministically — set explicit sizes after any
|
|
# pane toggle (same lesson as _set_projects_collapsed).
|
|
total = sum(self._split.sizes()) or 1300
|
|
# A collapsed pane must be given the STRIP width here, not its open
|
|
# width: this ran after every tab change and handed History a flat
|
|
# 240px even while it was folded to an 18px strip, leaving ~220px of
|
|
# dead space beside the chat on a small screen.
|
|
strip_w = CollapseStrip.WIDTH + 2
|
|
proj_w = 0
|
|
if on_project:
|
|
proj_w = strip_w if self._projects_strip.isVisible() else 260
|
|
hist_w = 0
|
|
if on_cowork and self._sidebar is not None:
|
|
hist_w = strip_w if self._sidebar.is_collapsed() else 240
|
|
if self._split.count() >= 3:
|
|
self._split.setSizes([proj_w, hist_w, max(1, total - proj_w - hist_w)])
|
|
else:
|
|
self._split.setSizes([proj_w, max(1, total - proj_w)])
|
|
|
|
# ---- i18n ------------------------------------------------------------
|
|
def _retranslate(self) -> None:
|
|
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, gợi ý và tên các sub-tab."""
|
|
self._header.setText(tr("workspace.header"))
|
|
self._hint.setText(tr("workspace.hint"))
|
|
self._projects_hdr.setText(tr("workspace.projects_heading").upper())
|
|
self._folder_hdr.setText(tr("workspace.folder_label"))
|
|
self._new_btn.setText(tr("workspace.new_project"))
|
|
self._del_btn.setText(tr("workspace.delete"))
|
|
self._name_lbl.setText(tr("workspace.name"))
|
|
self._desc_lbl.setText(tr("workspace.description"))
|
|
self._instr_lbl.setText(tr("workspace.instructions"))
|
|
self.instr_edit.setPlaceholderText(tr("workspace.instructions_placeholder"))
|
|
self._browse_btn.setText(tr("workspace.browse"))
|
|
self._browse_btn.setToolTip(tr("workspace.browse_tooltip"))
|
|
self._open_btn.setText(tr("workspace.open_folder"))
|
|
self._cloud_pick_btn.setText(tr("workspace.cloud_pick"))
|
|
self._cloud_sync_btn.setText(tr("workspace.cloud_sync"))
|
|
self._refresh_cloud_badge()
|
|
self._save_btn.setText(tr("workspace.save"))
|
|
self.retranslate_project_rows()
|
|
self._proj_collapse_btn.setToolTip(tr("workspace.collapse_projects_tooltip"))
|
|
self._projects_strip.setToolTip(tr("workspace.expand_projects_tooltip"))
|
|
self.tabs.setTabText(self._project_tab_idx, tr("workspace.tab_project"))
|
|
if self._cowork_tab_idx >= 0:
|
|
self.tabs.setTabText(self._cowork_tab_idx, tr("workspace.tab_cowork"))
|
|
if self._co4e_tab_idx >= 0:
|
|
self.tabs.setTabText(self._co4e_tab_idx, tr("workspace.tab_co4e"))
|
|
self.tabs.setTabToolTip(self._co4e_tab_idx, tr("workspace.tab_co4e_tooltip"))
|
|
if getattr(self, "_folder_tab_idx", -1) >= 0:
|
|
self.tabs.setTabText(self._folder_tab_idx, tr("workspace.tab_folder"))
|
|
if self._graphrag_tab_idx >= 0:
|
|
self.tabs.setTabText(self._graphrag_tab_idx, tr("workspace.tab_graphrag"))
|
|
|
|
# ---- project list collapse (same pattern as History / GraphRAG Agent panel) --
|
|
# Below this window width the three panes (projects 260 + history 240 +
|
|
# the sub-page, which alone wants ~1245px on Cowork) no longer fit and Qt
|
|
# clips them instead of shrinking. Measured with tools/check_responsive.py.
|
|
_NARROW = 1500
|
|
|
|
def showEvent(self, e): # noqa: N802 - Qt override
|
|
"""Lần hiện đầu tiên mới gắn bộ canh bố cục hẹp — trước đó chưa biết bề rộng thật."""
|
|
super().showEvent(e)
|
|
if getattr(self, "_narrow", None) is None:
|
|
from .widgets import narrow_guard
|
|
self._narrow = narrow_guard(self, self._NARROW, self._apply_narrow)
|
|
self._narrow.attach()
|
|
|
|
def _apply_narrow(self, narrow: bool) -> None:
|
|
"""Fold the two side panes on a small screen so the sub-page keeps its
|
|
width; unfold them when the window grows back.
|
|
|
|
Nothing becomes unreachable: both panes leave their usual collapse strip
|
|
behind, and the project picker + RECENTS in the rail cover the same
|
|
ground while they are folded.
|
|
"""
|
|
self._is_narrow = narrow
|
|
self._set_projects_collapsed(narrow)
|
|
if self._sidebar is not None:
|
|
self._set_sidebar_collapsed(narrow)
|
|
# The chat's Files pane (~300px) is the other thing that pushes Cowork
|
|
# past the window; it has the same collapse strip to come back from.
|
|
if self._cowork is not None and hasattr(self._cowork, "_set_io_collapsed"):
|
|
self._cowork._set_io_collapsed(narrow)
|
|
if not narrow:
|
|
# Re-apply the per-tab rules the two calls above just overrode.
|
|
self._apply_pane_visibility()
|
|
|
|
def _set_projects_collapsed(self, collapsed: bool) -> None:
|
|
"""Gập/mở cột project, đổi giữa panel đầy đủ và dải mỏng."""
|
|
strip_w = CollapseStrip.WIDTH + 2
|
|
self._projects_panel.setVisible(not collapsed)
|
|
self._projects_strip.setVisible(collapsed)
|
|
if collapsed:
|
|
self._projects_pane.setMaximumWidth(strip_w)
|
|
# A maximumWidth constraint alone doesn't make QSplitter hand the
|
|
# freed space to the OTHER pane(s) — it must be told explicitly
|
|
# (same fix as StructureGraphView._set_agent_collapsed), otherwise
|
|
# the tabs pane on the right stays stuck at its old (narrower)
|
|
# size. The freed width goes to the LAST pane (tabs) regardless of
|
|
# whether History occupies a middle pane or not.
|
|
sizes = self._split.sizes()
|
|
if len(sizes) >= 2:
|
|
freed = sizes[0] - strip_w
|
|
sizes[0] = strip_w
|
|
sizes[-1] = max(1, sizes[-1] + freed)
|
|
self._split.setSizes(sizes)
|
|
else:
|
|
self._projects_pane.setMaximumWidth(16777215) # QWIDGETSIZE_MAX
|
|
if self._sidebar is not None:
|
|
self._split.setSizes([260, 240, 800])
|
|
else:
|
|
self._split.setSizes([260, 900])
|
|
|
|
# ---- data ------------------------------------------------------------
|
|
def refresh_ai_models(self) -> None:
|
|
"""Reload the Folder tab's AI-edit model picker for the active provider —
|
|
called when the active provider changes so the picker never keeps a
|
|
stale model list from the old provider.
|
|
|
|
R08-T12 moved the picker off ``FolderTab`` and onto the AI-Edit panel
|
|
(``ai_panel.resolver``). The old guard here tested for
|
|
``folder.ai_model_combo``, an attribute that no longer exists on the
|
|
tab, so this hook silently did nothing and a provider switch left the
|
|
picker listing the previous provider's models. Reach the resolver
|
|
directly instead.
|
|
|
|
Refreshes unconditionally, exactly as the pre-refactor tab did. Gating
|
|
on "the picker already holds a list" looks tidier but breaks the case
|
|
that matters most: the first fetch failing (endpoint down, no network)
|
|
leaves the list empty, and the user switching provider afterwards is
|
|
precisely when the retry has to happen."""
|
|
folder = getattr(self, "_folder", None)
|
|
panel = getattr(folder, "ai_panel", None)
|
|
resolver = getattr(panel, "resolver", None)
|
|
if resolver is not None:
|
|
resolver.refresh()
|
|
|
|
def refresh(self) -> None:
|
|
"""Re-list projects, keeping the current selection when possible. No
|
|
auto-seed: an empty workspace stays empty (the user must create a
|
|
project before Cowork/GraphRAG appear — see _update_tab_visibility)."""
|
|
from ..core.projects import list_projects
|
|
|
|
keep = self._current_id
|
|
counts = self._project_counts()
|
|
self.project_list.blockSignals(True)
|
|
self.project_list.clear()
|
|
# -1 chứ không phải 0: chưa chọn gì thì KHÔNG tự chọn hộ project đầu
|
|
# danh sách. Chọn hộ là mở luôn cổng Cowork/GraphRAG (xem
|
|
# _update_tab_visibility) cho một project người dùng chưa hề bấm vào —
|
|
# lúc mở app, và cả sau khi xoá project đang mở. Có ``keep`` khớp thì
|
|
# vẫn giữ đúng dòng cũ như trước.
|
|
row_to_select = -1
|
|
for i, p in enumerate(list_projects()):
|
|
chats, tasks = counts.get(p.project_id, (0, 0))
|
|
# No text on the item: the row widget paints the name, and setting
|
|
# both drew it twice, one string ghosting the other.
|
|
item = QListWidgetItem()
|
|
item.setData(Qt.UserRole, p.project_id)
|
|
if p.description:
|
|
item.setToolTip(p.description)
|
|
self.project_list.addItem(item)
|
|
row = ProjectRow(p.name, chats, tasks)
|
|
item.setSizeHint(row.sizeHint())
|
|
self.project_list.setItemWidget(item, row)
|
|
if p.project_id == keep:
|
|
row_to_select = i
|
|
self.project_list.blockSignals(False)
|
|
self.project_list.setCurrentRow(row_to_select)
|
|
# The drawing heads a populated screen with the title alone; the
|
|
# explanation is what an EMPTY one says instead of showing nothing.
|
|
self._hint.setVisible(self.project_list.count() == 0)
|
|
self._load_current()
|
|
|
|
@staticmethod
|
|
def _project_counts():
|
|
"""{project_id: (chats, tasks)} — read once per refresh, not per row."""
|
|
from ..core.history import history_dirs, list_conversations_by_project
|
|
from ..core.tasks import list_tasks
|
|
|
|
out: dict = {}
|
|
for conv in list_conversations_by_project(history_dirs()):
|
|
pid = conv.get("project_id") or "default"
|
|
chats, tasks = out.get(pid, (0, 0))
|
|
out[pid] = (chats + 1, tasks)
|
|
for task in list_tasks():
|
|
pid = task.get("project_id") or "default"
|
|
chats, tasks = out.get(pid, (0, 0))
|
|
out[pid] = (chats, tasks + 1)
|
|
return out
|
|
|
|
def _selected_id(self) -> str:
|
|
"""Id project đang chọn trong danh sách; '' nếu chưa chọn gì."""
|
|
item = self.project_list.currentItem()
|
|
return item.data(Qt.UserRole) if item else ""
|
|
|
|
def _select_project_row(self, project_id: str) -> bool:
|
|
"""Chọn dòng ứng với một project id; trả về ``False`` nếu không tìm thấy."""
|
|
for i in range(self.project_list.count()):
|
|
if self.project_list.item(i).data(Qt.UserRole) == project_id:
|
|
self.project_list.setCurrentRow(i)
|
|
return True
|
|
return False
|
|
|
|
def _on_select(self, *_a) -> None:
|
|
"""Đổi dòng chọn trong danh sách project: nạp project đó lên form."""
|
|
self._load_current()
|
|
|
|
def _load_current(self) -> None:
|
|
"""Nạp project đang chọn lên form và nối mọi sub-tab vào nó."""
|
|
from ..core.projects import load_project, project_history_dir
|
|
|
|
pid = self._selected_id()
|
|
self._current_id = pid
|
|
# Deactivate the Cowork/GraphRAG panes while (re)loading — creating a
|
|
# project or switching to another one re-binds their sandbox/history
|
|
# underneath them, so they must not look interactive mid-transition.
|
|
self._set_tabs_busy(True)
|
|
try:
|
|
project = load_project(pid) if pid else None
|
|
# Route conversation history INTO the project's workspace folder so
|
|
# sharing that folder shares the history (another machine can view +
|
|
# continue). No project → global history dir (attribute cleared).
|
|
if project is not None:
|
|
self.ctx.config._project_history_dir = project_history_dir(project)
|
|
else:
|
|
self.ctx.config._project_history_dir = None
|
|
if project is not None:
|
|
self.name_edit.setText(project.name)
|
|
self.desc_edit.setText(project.description)
|
|
self.instr_edit.setPlainText(project.instructions)
|
|
self.folder_lbl.setText(str(project.workspace_dir()))
|
|
self._del_btn.setEnabled(True)
|
|
self._reload_threads()
|
|
if getattr(self, "_folder", None) is not None:
|
|
self._folder.set_project_root(str(project.workspace_dir()))
|
|
else:
|
|
self.name_edit.clear()
|
|
self.desc_edit.clear()
|
|
self.instr_edit.clear()
|
|
self.folder_lbl.setText("")
|
|
self._del_btn.setEnabled(False)
|
|
self._refresh_cloud_badge(project)
|
|
# Show Cowork/GraphRAG ONLY when a project is actually selected.
|
|
self._update_tab_visibility(project is not None)
|
|
# Bind the embedded Cowork/GraphRAG/History to this project's sandbox.
|
|
self._bind_project(pid)
|
|
finally:
|
|
self._set_tabs_busy(False)
|
|
# The rail's project picker mirrors this selection — it is a second view
|
|
# of the same state, never a second source of truth.
|
|
self.project_selected.emit(pid)
|
|
|
|
# ---- rail integration -------------------------------------------------
|
|
def project_choices(self):
|
|
"""(name, project_id) for the rail picker, in the list's own order.
|
|
|
|
Read from the store, which is what the list is built from — reading
|
|
item.text() coupled the picker to how a row happens to be drawn, and
|
|
when rows became widgets the picker went blank: every project showed
|
|
as a bare folder glyph with no name.
|
|
"""
|
|
from ..core.projects import list_projects
|
|
|
|
return [(p.name, p.project_id) for p in list_projects()]
|
|
|
|
def selected_project_id(self) -> str:
|
|
"""Id project đang chọn — lối vào công khai cho lớp ngoài."""
|
|
return self._selected_id()
|
|
|
|
def choose_project(self, project_id: str) -> bool:
|
|
"""Select a project by id — the same path the list row takes."""
|
|
return self._select_project_row(project_id)
|
|
|
|
def recent_threads(self, limit: int = 5):
|
|
"""The active project's most recent conversations, newest first.
|
|
|
|
Scoped to the project on purpose: history is stored inside the project's
|
|
own folder (config.history_dir() follows the selection) and the History
|
|
pane groups by project. A flat, cross-project recents list would quietly
|
|
drop that scoping.
|
|
"""
|
|
from ..core.history import list_conversations
|
|
|
|
pid = self._current_id
|
|
if not pid:
|
|
return []
|
|
try:
|
|
convos = list_conversations(self.ctx.config.history_dir())
|
|
except Exception: # noqa: BLE001
|
|
return []
|
|
out = []
|
|
for meta in convos:
|
|
if (meta.get("project_id", "") or "default") != pid:
|
|
continue
|
|
out.append({
|
|
"title": meta.get("title") or tr("sidebar.empty"),
|
|
"path": str(meta["path"]),
|
|
"kind": meta.get("kind", "") or "cowork",
|
|
"pinned": bool(meta.get("pinned", False)),
|
|
"session_id": meta.get("session_id", ""),
|
|
})
|
|
if len(out) >= limit:
|
|
break
|
|
return out
|
|
|
|
def open_thread(self, path: str, kind: str = "cowork") -> bool:
|
|
"""Open a conversation by file path — the same route the History pane's
|
|
own click takes (load_conversation → _on_sidebar_open)."""
|
|
from ..core.history import load_conversation
|
|
|
|
try:
|
|
conv = load_conversation(path)
|
|
except Exception: # noqa: BLE001
|
|
return False
|
|
self._on_sidebar_open(kind, conv)
|
|
return True
|
|
|
|
def show_history_pane(self) -> None:
|
|
"""Bring the full History panel into view (un-collapsing it if needed).
|
|
|
|
The rail's recents list is a shortcut, not a replacement: search,
|
|
filters, pin, rename, multi-select delete and the context menu all still
|
|
live in this panel.
|
|
"""
|
|
self._history_opened = True
|
|
self._show_cowork_tab()
|
|
self._sidebar.setVisible(True)
|
|
self._set_sidebar_collapsed(False)
|
|
|
|
def start_new_chat(self) -> None:
|
|
"""Start a new thread in the current project and show it.
|
|
|
|
Exactly what the History pane's own new-chat button does
|
|
(_on_sidebar_new); the rail button is a second door to the same room,
|
|
not a second implementation.
|
|
"""
|
|
self._on_sidebar_new("cowork")
|
|
|
|
def _set_tabs_busy(self, busy: bool) -> None:
|
|
"""Khoá/mở các sub-tab phụ thuộc project trong lúc đang chuyển project."""
|
|
for idx in (self._cowork_tab_idx, self._graphrag_tab_idx):
|
|
if idx >= 0:
|
|
widget = self.tabs.widget(idx)
|
|
if widget is not None:
|
|
widget.setEnabled(not busy)
|
|
|
|
def _update_tab_visibility(self, has_project: bool) -> None:
|
|
"""Cowork + GraphRAG tabs are visible only while a project is
|
|
selected; otherwise the screen shows just the Project (management)
|
|
tab."""
|
|
for idx in (self._cowork_tab_idx, self._graphrag_tab_idx):
|
|
if idx >= 0:
|
|
self.tabs.setTabVisible(idx, has_project)
|
|
if not has_project:
|
|
self.tabs.setCurrentIndex(self._project_tab_idx)
|
|
# setCurrentIndex doesn't fire currentChanged when the Project tab is
|
|
# already current — re-apply the pane rules explicitly.
|
|
self._apply_pane_visibility()
|
|
self.subtabs_changed.emit() # left-nav children follow visible sub-tabs
|
|
|
|
def _bind_project(self, pid: str) -> None:
|
|
# This is THE central project-switch hook — make the selected project the
|
|
# ACTIVE workspace so per-workspace modes (routing + auto-run) resolve
|
|
# against it, then refresh every surface's toggles to show its modes.
|
|
"""Đặt project đang hoạt động và làm mới mọi thứ phụ thuộc nó.
|
|
|
|
Đây là hook chuyển project TRUNG TÂM: đặt ``ctx.active_project_id`` để chế
|
|
độ theo-workspace (định tuyến, tự chạy) phân giải đúng, rồi làm mới công
|
|
tắc trên từng bề mặt cho khớp.
|
|
"""
|
|
self.ctx.active_project_id = pid or "default"
|
|
self._refresh_mode_toggles()
|
|
if self._structure is not None:
|
|
self._structure.set_workspace_project(pid)
|
|
if self._sidebar is not None:
|
|
self._sidebar.set_project_filter(pid) # "" → show all (no project selected)
|
|
# Route Co4E flow output into THIS project's workspace folder (so flow
|
|
# files land in the selected workspace, like Cowork — not the config dir).
|
|
if self._co4e is not None:
|
|
self._co4e.set_project(pid)
|
|
if self._cowork is not None and pid:
|
|
# Switch to a fresh thread in the newly selected project (past
|
|
# threads are reopened from History), unless the current thread is
|
|
# already in it.
|
|
if getattr(self._cowork, "project_id", "") != pid:
|
|
self._cowork.new_session()
|
|
self._cowork.set_project(pid)
|
|
|
|
def _refresh_mode_toggles(self) -> None:
|
|
"""Re-point every surface's Off/Auto/Manual + Auto-run toggles at the
|
|
ACTIVE workspace's modes (called whenever the selected project changes)."""
|
|
targets = [
|
|
(self._cowork, "routing_toggle"),
|
|
(self._cowork, "autorun_toggle"),
|
|
(self._co4e, "co4e_routing_toggle"),
|
|
(self._folder, "ai_routing_toggle"),
|
|
]
|
|
for widget, attr in targets:
|
|
toggle = getattr(widget, attr, None) if widget is not None else None
|
|
if toggle is not None:
|
|
toggle.refresh()
|
|
|
|
def _reload_threads(self) -> None:
|
|
# The threads list was removed from the Project tab; nothing to reload.
|
|
"""Nạp lại danh sách luồng chat của project.
|
|
|
|
Danh sách này đã bị gỡ khỏi tab Dự án nên thường là no-op; giữ lại để mã
|
|
cũ còn gọi tới không vỡ.
|
|
"""
|
|
if not hasattr(self, "threads"):
|
|
return
|
|
from ..core.history import list_conversations
|
|
|
|
self.threads.clear()
|
|
pid = self._current_id or "default"
|
|
for conv in list_conversations(self.ctx.config.history_dir()):
|
|
if conv.get("project_id", "default") != pid:
|
|
continue
|
|
label = conv["title"]
|
|
if conv.get("created"):
|
|
label += f" · {conv['created'][:16].replace('T', ' ')}"
|
|
item = QTreeWidgetItem([label])
|
|
item.setData(0, Qt.UserRole, str(conv["path"]))
|
|
self.threads.addTopLevelItem(item)
|
|
|
|
# ---- actions -----------------------------------------------------------
|
|
def _pick_folder(self) -> None:
|
|
"""Chọn thư mục sandbox cho project đang mở."""
|
|
from ..core.projects import load_project, save_project
|
|
|
|
pid = self._current_id
|
|
project = load_project(pid) if pid else None
|
|
if project is None:
|
|
return
|
|
chosen = QFileDialog.getExistingDirectory(
|
|
self, tr("workspace.browse_tooltip"), str(project.workspace_dir()))
|
|
if not chosen:
|
|
return
|
|
project.output_dir = chosen
|
|
save_project(project)
|
|
self.folder_lbl.setText(chosen)
|
|
self.status_message.emit(tr("workspace.saved", name=project.name))
|
|
|
|
def _open_workspace(self) -> None:
|
|
"""Mở thư mục sandbox của project trong trình quản lý tệp của hệ điều hành."""
|
|
from ..core.projects import load_project, project_history_dir
|
|
|
|
project = load_project(self._current_id) if self._current_id else None
|
|
if project is None:
|
|
return
|
|
wd = project.workspace_dir()
|
|
wd.mkdir(parents=True, exist_ok=True)
|
|
open_folder(str(wd))
|
|
|
|
def _cloud_token(self):
|
|
"""Lấy access token MS365 hiện tại theo cấu hình — raise
|
|
``Ms365AuthError`` nếu chưa đăng nhập/hết hạn (gọi picker trước nên
|
|
thường đã có sẵn phiên đăng nhập)."""
|
|
from ..core.ms365_auth import get_access_token
|
|
|
|
ms365 = (self.ctx.config.ms365 or {})
|
|
return get_access_token(ms365.get("tenant_id", ""), ms365.get("client_id", ""))
|
|
|
|
def _pick_cloud_folder(self) -> None:
|
|
"""DF-007 — duyệt OneDrive/SharePoint qua Graph API, tải một bản
|
|
mirror cục bộ xuống rồi dùng bản mirror đó làm output_dir của
|
|
project. Xem core/cloud_workspace_sync.py cho giới hạn (một chiều,
|
|
thủ công, không đồng bộ liên tục, không xử lý xung đột)."""
|
|
from ..core.cloud_workspace_sync import download_folder
|
|
from ..core.ms365_auth import Ms365AuthError
|
|
from ..core.projects import WORKSPACES_DIR, load_project, save_project
|
|
from .cloud_folder_picker_dialog import pick_cloud_folder
|
|
|
|
pid = self._current_id
|
|
project = load_project(pid) if pid else None
|
|
if project is None:
|
|
return
|
|
cloud_source = pick_cloud_folder(self, self.ctx.config)
|
|
if not cloud_source:
|
|
return
|
|
local_dir = WORKSPACES_DIR / project.project_id / "_cloud_mirror"
|
|
self._cloud_pick_btn.setEnabled(False)
|
|
try:
|
|
token = self._cloud_token()
|
|
report = download_folder(token, cloud_source, local_dir)
|
|
except Ms365AuthError as exc:
|
|
QMessageBox.warning(self, tr("workspace.cloud_pick"), str(exc))
|
|
return
|
|
finally:
|
|
self._cloud_pick_btn.setEnabled(True)
|
|
project.output_dir = str(local_dir)
|
|
project.cloud_source = cloud_source
|
|
save_project(project)
|
|
self.folder_lbl.setText(str(local_dir))
|
|
self._refresh_cloud_badge(project)
|
|
self.projects_changed.emit()
|
|
if report.errors:
|
|
QMessageBox.warning(self, tr("workspace.cloud_pick"),
|
|
tr("workspace.cloud_sync_errors", n=len(report.errors)))
|
|
self.status_message.emit(tr("workspace.saved", name=project.name))
|
|
|
|
def _sync_cloud_folder(self) -> None:
|
|
"""DF-007 — đẩy thay đổi cục bộ lên cloud rồi tải lại (một chiều mỗi
|
|
bước, thủ công, chạy khi bấm nút). Không xoá file 2 phía, không phát
|
|
hiện xung đột — xem core/cloud_workspace_sync.py."""
|
|
from ..core.cloud_workspace_sync import download_folder, upload_folder
|
|
from ..core.ms365_auth import Ms365AuthError
|
|
from ..core.projects import load_project
|
|
|
|
pid = self._current_id
|
|
project = load_project(pid) if pid else None
|
|
if project is None or not project.cloud_source:
|
|
return
|
|
self._cloud_sync_btn.setEnabled(False)
|
|
try:
|
|
token = self._cloud_token()
|
|
up_report = upload_folder(token, project.cloud_source, project.workspace_dir())
|
|
down_report = download_folder(token, project.cloud_source, project.workspace_dir())
|
|
except Ms365AuthError as exc:
|
|
QMessageBox.warning(self, tr("workspace.cloud_sync"), str(exc))
|
|
return
|
|
finally:
|
|
self._cloud_sync_btn.setEnabled(True)
|
|
lines = [tr("workspace.cloud_sync_result",
|
|
up=up_report.transferred, down=down_report.transferred)]
|
|
n_errors = len(up_report.errors) + len(down_report.errors)
|
|
if n_errors:
|
|
lines.append(tr("workspace.cloud_sync_errors", n=n_errors))
|
|
n_skipped = len(up_report.skipped_too_large)
|
|
if n_skipped:
|
|
lines.append(tr("workspace.cloud_sync_skipped", n=n_skipped))
|
|
QMessageBox.information(self, tr("workspace.cloud_sync"), "\n".join(lines))
|
|
|
|
def _refresh_cloud_badge(self, project=None) -> None:
|
|
"""Hiện/ẩn badge ☁ + nút Đồng bộ theo project đang mở có phải một
|
|
mirror cloud hay không (``project.cloud_source``)."""
|
|
if project is None:
|
|
from ..core.projects import load_project
|
|
|
|
project = load_project(self._current_id) if self._current_id else None
|
|
cloud_source = project.cloud_source if project is not None else None
|
|
if not cloud_source:
|
|
self._cloud_badge_lbl.hide()
|
|
self._cloud_sync_btn.hide()
|
|
return
|
|
if cloud_source.get("provider") == "sharepoint":
|
|
text = tr("workspace.cloud_badge_sharepoint",
|
|
site=cloud_source.get("site_name", ""),
|
|
path=cloud_source.get("remote_path", "") or "/")
|
|
else:
|
|
text = tr("workspace.cloud_badge_onedrive",
|
|
path=cloud_source.get("remote_path", "") or "/")
|
|
self._cloud_badge_lbl.setText(text)
|
|
self._cloud_badge_lbl.show()
|
|
self._cloud_sync_btn.show()
|