Files
cowork-local/presentation/workspace/project_editing.py
T
a03a740ea1
CI / test (push) Canceled after 0s
Feature/perf ui logic (#13)
## Summary

Nhánh `feature/perf-ui-logic`: tối ưu hiệu năng/UI, sửa lỗi workspace và điều hướng, và làm cho công tắc **"Block network for agent-run commands"** chặn thật mọi đường ra mạng của app, **trừ nhà cung cấp AI**.

**Chặn mạng (b78d483, 8c497cf, 10b8379)**
- Bộ kiểm tra chung `application/network/network_guard.py`, nối vào cấu hình đang chạy ở Composition Root: đổi công tắc trong Settings là có hiệu lực ngay.
- Lệnh shell của agent và task script chạy trong **Windows AppContainer không có quyền mạng**: kernel chặn socket, ping, DNS, Invoke-WebRequest… Không cần quyền admin. Không cô lập được thì lệnh bị từ chối, không chạy khi mạng còn mở. macOS dùng `sandbox-exec`, Linux dùng `unshare --net`.
- Bật chặn thì: dừng MCP đang chạy, không khởi động server mới, từ chối lời gọi connector; Microsoft 365 (đăng nhập, Graph, đồng bộ cloud, rules, mail), Teams, nút Test REST/Jira/MCP, link đính kèm task, pip tự cài và tài nguyên web trong xem trước HTML đều bị từ chối.
- Vẫn dùng được: chat, tải danh sách model, thử model; tool OneDrive đã đồng bộ trên máy.
- Công tắc **mặc định tắt** khi mở app lần đầu; nhãn giữ nguyên như cũ.
- Xem trước HTML trong tab Folder giờ hiện được ảnh/CSS/JS từ web khi mạng mở (trước đây trang `file://` không tải được).
- Sửa lỗi app văng khi chuyển tab Graph → Folder: profile WebEngine của trang xem trước bị huỷ trước trang (`0xc0000409` trong Qt6Core.dll); giờ dùng một profile chung thuộc QApplication.
- Không cấp quyền AppContainer kế thừa lên thư mục chứa PySide6 (nếu có, Chromium không nạp được `Qt6WebEngineCore.dll` và tab Graph trắng).
- Cột mục lục trong Settings tính độ rộng theo kiểu chữ của mục đang chọn, "Sandbox Security Layer" không còn bị cắt.

**Các commit khác trong nhánh**
- `b7a41b3` mỗi thư mục làm việc chỉ thuộc về một project · `bbdf146` bật nút Sửa project khi đã có project đang mở
- `35f24e0`, `cc8d5c8`, `2e3e719`, `c699beb` canh hàng / khoảng cách thanh điều hướng
- `2759ed9` không refresh workspace khi chuyển tab Cowork · `7607f44` checkpoint hiệu năng và UI
- `8548c1e` chặn tool mạng của agent · `caf3b74` renderer GraphRAG native trên macOS · `c00b83c` khoảng cách metadata hàng project · `a04f8a9` ẩn picker workspace cloud

## Change Type

- [x] Cowork feature
- [x] Bug fix
- [ ] Core AI contribution
- [x] Test / hardening
- [x] 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?
- Mọi đường ra mạng do app tự mở, trừ nhà cung cấp AI (xem Summary).
- Test: `tests/test_network_guard_lanes.py` (có bài chạy AppContainer thật trên Windows), `tests/ui/test_html_preview_remote_images.py`.

What is intentionally NOT included?
- Chặn cả nhà cung cấp AI / chạy model trên máy (Phương án 2).
- Terminal người dùng tự gõ trong tab Folder, sinh ảnh, cơ chế tự tin chứng chỉ lạ (`tls_trust`).
- Huy hiệu trạng thái "đang chặn" trên thanh trên cùng.

## Validation

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

Commands / evidence:
- `python -m pytest tests/test_network_guard_lanes.py tests/test_sandbox_block_network.py tests/ui -q` → chỉ còn 1 bài fail, fail cả trên `b7a41b3` (nhãn `ProjectRow` 'Project' chưa dịch, `tests/ui/test_i18n_khong_con_chu_cu.py`).
- `python -m pytest tests -q --ignore=tests/ui` → 4 bài fail, cả 4 cũng fail trên `b7a41b3` (`test_canonical_audit_logger`, 2 bài `test_mcp_audit_security`, `test_monitoring_tab_container`).
- Chạy cả `tests` trong một lượt thì treo ở các test dựng MainWindow trong `tests/ui`; `b7a41b3` cũng treo đúng chỗ đó.
- `check_imports.py` và `check_orphan_modules.py` PASS. `check_loc.py` báo 9 file quá dài, giống hệt trước khi sửa (không file nào do nhánh này làm dài thêm).
- Kiểm tra tay trên Windows 11: trong AppContainer, Python báo `WinError 10013`, ping/nslookup/PowerShell/curl đều không ra được mạng; cmd, git, python chạy bình thường.
- Kiểm tra tay trên Windows 11: xem trước HTML tải được 4/4 tài nguyên web khi mạng mở, 0/4 khi bật chặn; tab Graph hoạt động; tạo/huỷ trang xem trước nhiều lần không còn cảnh báo profile của Qt.

## Security Impact

Permission / credential / network / customer data impact:
- Network: khi bật công tắc, chỉ nhà cung cấp AI còn ra mạng; nội dung chat vẫn gửi tới nhà cung cấp AI.
- Permission: lần đầu chạy lệnh trong sandbox, app **thêm quyền (ACE) cho SID AppContainer** trên thư mục làm việc (ghi), thư mục cài Python gốc (đọc), gốc venv và `Scripts` (đọc). Không xoá quyền nào. Thư mục chứa PySide6 không bao giờ nhận quyền kế thừa; một quyền kế thừa sai trên venv (từ bản dev trước) được tự gỡ.
- Credential: không đổi. Khi chặn, trạng thái đăng nhập M365 được đọc thẳng từ kho token trên máy, không dựng MSAL.

## Compatibility

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

Ghi chú: `block_network` mặc định đổi từ bật sang tắt cho cấu hình mới; máy đã lưu `true` thì giữ nguyên. Khi đang chặn, lệnh dùng công cụ cài trong thư mục người dùng (ngoài Program Files) có thể báo Access denied; thư viện trong venv của app không dùng được trong sandbox.

## Reviewer Notes

- `infrastructure/sandbox/appcontainer_process.py` gọi Win32 bằng ctypes (CreateAppContainerProfile, CreateProcessW với SECURITY_CAPABILITIES) và dùng `icacls` để cấp quyền: nên xem kỹ phần cấp quyền.
- `tests/conftest.py` thêm fixture autouse gỡ `network_guard` sau mỗi test, vì `build_context()` gắn cổng này ở mức process.
- `core/task_executors.py` đang đúng bằng trần LOC nên `_run_script` được tách sang `core/task_script.py`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: minhanhpkpro <minhanhpkpro@gmail.com>
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
Co-authored-by: thanhnv <thanhnv.ip@gmail.com>
Reviewed-on: #13
2026-09-20 12:26:03 +00:00

361 lines
17 KiB
Python

"""Danh sách project và việc sửa một project — bóc khỏi ``ui/workspace_tab.py``.
Bóc ra vì ``ui/workspace_tab.py`` đã chạm đúng trần bánh cóc của nó trong
``scripts/check_loc.py::LEGACY_ALLOWANCE`` (566 dòng mã): file đó chỉ được co
lại, không được phình thêm. Bốn tính năng dưới đây đều thêm mã vào cùng một chỗ,
nên chúng đi cùng nhau sang đây thay vì bị nhét vào một file đã đầy.
Gộp bốn thứ vào một module không phải để lách trần — chúng là một khối:
* **Chế độ chỉ-xem.** Project đã có mở ra ở dạng chỉ đọc; phải bấm "Sửa project"
mới gõ được. Trước đây form luôn mở, nên chỉ cần lỡ tay là sửa mất nội dung của
một project đang dùng mà không có gì cảnh báo.
* **Chặn trùng tên.** Hai project cùng tên thì danh sách bên trái không còn phân
biệt được, mà tên lại là thứ duy nhất dòng đó hiển thị.
* **Menu chuột phải.** Bấm phải vào một project trước đây không làm gì cả.
* **Dựng lại nhãn số liệu khi đổi ngôn ngữ.** ``workspace.counts`` được format
một lần lúc dựng dòng, nên đổi ngôn ngữ xong nó vẫn nằm ở ngôn ngữ cũ.
Ba việc đầu phải nằm chung: "Sửa" trong menu chuột phải bật đúng chế độ mà nút
"Sửa project" bật, và việc chặn trùng tên chỉ có nghĩa khi biết form đang sửa
project nào.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QLabel, QLayout, QMenu, QMessageBox, QPushButton, QVBoxLayout, QWidget,
)
from ...i18n import tr
from ...ui.dialog_buttons import confirm
#: Ten project mac dinh. Co y KHONG dich — xem ghi chu trong ``_create``.
_DEFAULT_PROJECT_NAME = "Project"
class ProjectRow(QWidget):
"""A project in the list: its name, and under it how much is in it.
The drawing gives every row a second line — "2 đoạn chat · 3 task" — which
is the only thing on this screen that says a project holds anything at all.
Số liệu được giữ lại dưới dạng SỐ (``chats``/``tasks``) chứ không chỉ chuỗi
đã format, để đổi ngôn ngữ còn dựng lại được câu mà không phải đọc lại đĩa.
"""
def __init__(self, name: str, chats: int, tasks: int) -> None:
"""Một dòng dự án trong danh sách: tên ở trên, số liệu tóm tắt ở dưới."""
super().__init__()
self.chats = chats
self.tasks = tasks
lay = QVBoxLayout(self)
lay.setContentsMargins(6, 4, 6, 4)
lay.setSpacing(3)
self.title_label = QLabel(name)
self.counts_label = QLabel()
self.counts_label.setObjectName("hint")
lay.addWidget(self.title_label)
lay.addWidget(self.counts_label)
self.retranslate()
def retranslate(self) -> None:
"""Dựng lại dòng số liệu theo ngôn ngữ đang chọn."""
self.counts_label.setText(
tr("workspace.counts", chats=self.chats, tasks=self.tasks))
def _row_layout_of(widget: QWidget) -> QLayout | None:
"""Layout đang trực tiếp chứa ``widget``.
Qt không cho hỏi ngược "ai đang giữ tôi", nên phải duyệt cây layout của
widget cha. Dùng để chèn nút "Sửa project" ngay cạnh nút Lưu mà không phải
thêm dòng nào vào ``_build_project_tab`` (xem docstring đầu file).
"""
parent = widget.parentWidget()
if parent is None:
return None
stack = [parent.layout()]
while stack:
layout = stack.pop()
if layout is None:
continue
for i in range(layout.count()):
item = layout.itemAt(i)
if item.widget() is widget:
return layout
if item.layout() is not None:
stack.append(item.layout())
return None
class ProjectEditingMixin:
"""Danh sách project + CRUD + chế độ sửa. Trộn vào ``WorkspaceTab``.
Cùng kiểu mixin với ``presentation/shell/`` (``NavRailMixin``,
``RailProjectMixin``, …): lớp này đọc thẳng các thuộc tính mà
``WorkspaceTab`` dựng (``project_list``, ``name_edit``, ``_save_btn``, …)
nên nó không dùng lại được ở nơi khác — mục đích là chia file, không phải
dựng một lớp trừu tượng.
"""
# ---- lắp đặt ---------------------------------------------------------
def install_project_editing(self) -> None:
"""Dựng nút "Sửa project", nối menu chuột phải, và khoá form lại.
Gọi một lần, sau khi ``_build_project_tab`` đã dựng xong các widget.
"""
self._project_editable = False
self._edit_btn = QPushButton()
self._edit_btn.setObjectName("warning")
self._edit_btn.clicked.connect(self.enter_project_edit_mode)
# Ba nut cua man quan ly project ve chung MOT hang, canh "Project moi" o
# hang tieu de. Truoc do "Luu project" nam duoi cung khung ben phai, cach
# "Project moi" gan het chieu cao man hinh, nen ba viec cung mot nhom lai
# o ba cho khac nhau. Hang tieu de vat ngang ca man Workspace va da chi
# hien tren sub-tab Project (xem _new_btn.setVisible(on_project)).
title_row = _row_layout_of(self._new_btn)
if title_row is not None:
at = title_row.indexOf(self._new_btn) + 1
title_row.insertWidget(at, self._edit_btn)
title_row.insertWidget(at + 1, self._save_btn)
else:
save_row = _row_layout_of(self._save_btn)
if save_row is not None:
save_row.insertWidget(save_row.count() - 1, self._edit_btn)
# Hang tieu de vat ngang CA man Workspace, nen thu gi dat len do cung
# hien o Cowork, Co4E, Thu muc va GraphRAG neu khong tu an di. Do la
# dung luat ma _new_btn da theo (_new_btn.setVisible(on_project) trong
# WorkspaceTab._apply_pane_visibility) — hai nut nay phai theo y nhu vay.
self.tabs.currentChanged.connect(self._sync_project_buttons)
# Đổi project cũng phải đồng bộ lại: ``_load_current`` nạp form và đặt
# ``_current_id`` rồi phát tín hiệu này, nhưng không đụng tới ba nút.
self.project_selected.connect(self._sync_project_buttons)
self.project_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.project_list.customContextMenuRequested.connect(self._show_project_menu)
# Luật "mỗi thư mục một project" sống ở module riêng — xem
# ``project_folder_rules.py`` về lý do nó không nằm trong file này.
self.install_project_folder_rule()
self.set_project_editable(False)
# ---- chế độ chỉ-xem / sửa -------------------------------------------
def _editable_fields(self) -> list:
"""Các ô mà chế độ sửa mở khoá. ``folder_lbl`` không có ở đây: nó vốn
chỉ đọc, đường dẫn đổi qua nút Chọn thư mục."""
return [self.name_edit, self.desc_edit, self.instr_edit]
def set_project_editable(self, on: bool) -> None:
"""Bật/tắt chế độ sửa của form project.
Tắt: các ô thành chỉ đọc, nút Lưu và Chọn thư mục mờ đi, nút Sửa hiện ra.
Bật: ngược lại, và nút Lưu chuyển sang màu xác nhận (token ``success``).
"""
self._project_editable = on
for field in self._editable_fields():
# setReadOnly thay vì setEnabled: ô mờ đi thì không bôi đen copy
# được nữa, mà đọc và copy chính là việc của chế độ chỉ-xem.
field.setReadOnly(not on)
# Ba nút không tự bật/tắt ở đây: ``_sync_project_buttons`` mới là nơi
# duy nhất tính trạng thái của chúng, vì nó còn chạy cả khi người dùng
# đổi project — lúc đó ``set_project_editable`` không được gọi.
self._sync_project_buttons()
# Nút Lưu xanh lá khi đang sửa (hành động xác nhận), về màu nhấn mặc
# định khi chỉ xem. Màu lấy từ token trong theme/ — xem theme/__init__.py.
self._save_btn.setObjectName("success" if on else "primary")
self._repolish(self._save_btn)
self._repolish(self._edit_btn)
def _sync_project_buttons(self, *_a) -> None:
"""Đồng bộ CẢ hiện/ẩn LẪN bật/mờ của ba nút theo trạng thái hiện tại.
Ẩn ngoài sub-tab Project: chúng nằm trên hàng tiêu đề dùng chung, nên
không tự ẩn là chúng hiện cả ở Cowork — nơi không có biểu mẫu project
nào để sửa hay lưu.
Bật/mờ cũng tính ở đây chứ không ở ``set_project_editable``: đổi
project KHÔNG đi qua hàm đó (``_load_current`` chỉ nạp lại form), nên
để ở đó thì "Sửa project" giữ nguyên trạng thái tính từ lúc dựng —
lúc chưa project nào được chọn — và cứ mờ mãi dù project đã mở.
"""
on_project = self.tabs.currentIndex() == self._project_tab_idx
has_project = bool(getattr(self, "_current_id", ""))
dang_sua = bool(getattr(self, "_project_editable", False))
self._edit_btn.setVisible(on_project and has_project)
self._save_btn.setVisible(on_project and has_project)
self._edit_btn.setEnabled(not dang_sua and has_project)
self._save_btn.setEnabled(dang_sua and has_project)
self._browse_btn.setEnabled(dang_sua and has_project)
@staticmethod
def _repolish(widget: QWidget) -> None:
"""Ép Qt đánh giá lại style sau khi đổi ``objectName``.
Không có bước này thì QSS đã khớp lúc dựng vẫn giữ nguyên — đổi
objectName một mình không kích hoạt tính lại (bẫy P08).
"""
widget.style().unpolish(widget)
widget.style().polish(widget)
def enter_project_edit_mode(self) -> None:
"""Mở khoá form và đưa con trỏ vào ô Tên."""
if not getattr(self, "_current_id", ""):
return
self.set_project_editable(True)
self.name_edit.setFocus()
# ---- menu chuột phải -------------------------------------------------
def _show_project_menu(self, pos) -> None:
"""Menu chuột phải trên một dòng project: Mở / Sửa / Xoá.
Bấm phải vào một dòng trước đây không làm gì cả — ba việc này đều đã
có sẵn ở nơi khác trên màn hình, menu chỉ là đường vào thứ hai.
"""
item = self.project_list.itemAt(pos)
if item is None:
return
# Bấm phải cũng chọn dòng đó, để menu thao tác lên đúng thứ vừa bấm
# chứ không lên dòng đang được chọn từ trước.
self.project_list.setCurrentItem(item)
menu = QMenu(self.project_list)
open_act = menu.addAction(tr("workspace.menu_open"))
edit_act = menu.addAction(tr("workspace.menu_edit"))
menu.addSeparator()
del_act = menu.addAction(tr("workspace.menu_delete"))
chosen = menu.exec(self.project_list.viewport().mapToGlobal(pos))
if chosen is open_act:
self.open_selected_project()
elif chosen is edit_act:
self.enter_project_edit_mode()
elif chosen is del_act:
self._delete()
def open_selected_project(self) -> None:
"""Mở project đang chọn: sang sub-tab Cowork của chính nó."""
self._load_current()
self._show_cowork_tab()
# ---- trùng tên -------------------------------------------------------
def _name_taken(self, name: str, *, ignore_id: str = "") -> bool:
"""Đã có project khác mang tên này chưa.
So không phân biệt hoa thường và bỏ khoảng trắng hai đầu: với người
dùng thì "Báo cáo" và "báo cáo " là cùng một cái tên, mà tên lại là thứ
duy nhất dòng trong danh sách hiển thị.
"""
from ...core.projects import list_projects
wanted = (name or "").strip().casefold()
if not wanted:
return False
return any(p.name.strip().casefold() == wanted and p.project_id != ignore_id
for p in list_projects())
def _warn_duplicate_name(self, name: str) -> None:
"""Báo cho người dùng biết tên đã có, và nói rõ tên nào."""
QMessageBox.warning(self, tr("workspace.name_taken_title"),
tr("workspace.name_taken_body", name=name.strip()))
self.name_edit.setFocus()
self.name_edit.selectAll()
# ---- CRUD (chuyển từ ui/workspace_tab.py sang) -----------------------
def _create(self) -> None:
"""Tạo project mới với tên mặc định rồi chọn nó.
Tên mặc định phải là tên CHƯA có: người dùng bấm "Project mới" hai lần
liên tiếp thì lần thứ hai sẽ trùng lần thứ nhất.
"""
from ...core.projects import new_project
# KHONG dung tr() cho ten mac dinh: ten project duoc GHI XUONG DIA. Tao
# project luc dang o tieng Nhat thi ten no thanh "新規プロジェクト" vinh
# vien, va doi ngon ngu ve tieng Viet khong sua duoc — do la du lieu, khong
# phai chu giao dien. Nguoi dung nhin thay chu Nhat tren man hinh tieng
# Viet va tuong la loi hien thi. Mot ten trung tinh khong bao gio noi doi.
base = _DEFAULT_PROJECT_NAME
name, n = base, 2
while self._name_taken(name):
name, n = f"{base} ({n})", n + 1
project = new_project(name)
self._current_id = project.project_id
self.refresh()
self.projects_changed.emit()
# Project vừa tạo thì mở sẵn chế độ sửa: chưa đặt tên thì chưa dùng được.
self.set_project_editable(True)
self.name_edit.setFocus()
self.name_edit.selectAll()
def _delete(self) -> None:
"""Xoá project đang chọn sau khi hỏi xác nhận."""
from ...core.projects import delete_project, load_project
pid = self._selected_id()
project = load_project(pid) if pid else None
if project is None:
return
if not confirm(self, tr("workspace.delete"),
tr("workspace.delete_confirm", name=project.name)):
return
delete_project(pid)
self._current_id = ""
self.refresh() # empty workspace → Cowork/GraphRAG hidden until a new project
self.projects_changed.emit()
self.status_message.emit(tr("workspace.deleted", name=project.name))
def _save(self) -> None:
"""Lưu tên, mô tả và chỉ dẫn chung của 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
new_name = self.name_edit.text().strip()
# Chặn TRƯỚC khi ghi: ghi xong rồi mới báo là đã hỏng dữ liệu trên đĩa.
if new_name and self._name_taken(new_name, ignore_id=pid):
self._warn_duplicate_name(new_name)
return
project.name = new_name or project.name
project.description = self.desc_edit.text().strip()
project.instructions = self.instr_edit.toPlainText().strip()
save_project(project)
self.refresh()
self.projects_changed.emit()
self.status_message.emit(tr("workspace.saved", name=project.name))
# Lưu xong thì khoá lại: chế độ sửa là một lần sửa, không phải trạng
# thái dính cho tới khi người dùng nhớ ra phải tắt.
self.set_project_editable(False)
# ---- i18n ------------------------------------------------------------
def retranslate_project_rows(self) -> None:
"""Dựng lại nhãn số liệu của mọi dòng project theo ngôn ngữ mới.
KHÔNG gọi ``refresh()`` ở đây: ``refresh()`` kết thúc bằng
``_load_current()``, nạp lại form từ đĩa — đổi ngôn ngữ giữa chừng sẽ
xoá mất nội dung người dùng đang gõ dở.
"""
for i in range(self.project_list.count()):
row = self.project_list.itemWidget(self.project_list.item(i))
if isinstance(row, ProjectRow):
row.retranslate()
if hasattr(self, "_edit_btn"):
self._edit_btn.setText(tr("workspace.edit_project"))