Compare commits

..
Author SHA1 Message Date
vudt15andClaude Sonnet 5 f6c196ca87 fix(graphrag): crash on project rename/path change (DF-009)
session_events.py::_on_projects_changed (runs on every project
create/rename/delete) called self.structure._refresh_project_combo() —
but that method only ever existed on GraphRenderer, not on
StructureGraphView itself (self.structure). Renaming a project or
changing its working-directory path raised AttributeError and crashed
the app.

Add StructureGraphView.refresh_project_list(), matching the existing
schedule_rescan/set_project/prewarm forwarding pattern, and call that
instead. Add a regression test
(test_refresh_project_list_forwards_to_renderer).

Logged as DF-009 in Task Tracking Template.xlsx — root cause, fix and
regression test recorded (row was empty in this local copy despite
being reported as already logged there).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 09:46:33 +09:00
vudt15andClaude Sonnet 5 a20c7a2d69 docs(qa): log DF-007 investigation — feature already shipped, no fix needed
CI / test (pull_request) Canceled after 0s
Khảo sát cho thấy DF-007 (chọn thư mục làm việc là link cloud
OneDrive/SharePoint thay vì chỉ local) đã được xây dựng đầy đủ từ
commit f9f6bc0, trước cả đợt fix df002/df011. Không sửa code — chỉ
ghi lại bằng chứng vào Task Tracking Template.xlsx và đề nghị QA test
lại trên bản build mới nhất.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:19:02 +09:00
vudt15andClaude Sonnet 5 4f88171c64 fix(qa): resolve DF-002/DF-003/DF-006, log Defect Management sheet
DF-002 (Co4E): node đã chạy xong (STEP_DONE) vẫn bị khoá edit — chỉ khoá
khi node đang thực sự chạy (STEP_RUNNING). Phần "status sai khi back
tab" đã được xử lý từ trước (2a5ee29/13e2c22), xác minh vẫn nguyên vẹn.

DF-003: run.bat chỉ ẩn console ngay trước lúc chạy app, nên các bước
kiểm tra (Python/thư viện/junction) phía trước vẫn hiện console một
chút — ẩn ngay từ đầu file, hiện lại đúng lúc báo lỗi.

DF-006 (Monitoring): options số dòng/trang đổi thành 5/10/20/50/100
(mặc định 20); EventTable giờ phân trang thật (next/prev, page_count)
thay vì chỉ cắt top-N rồi bỏ phần còn lại.

Thêm tests/test_co4e_edit_lock.py (chưa có test nào phủ hành vi khoá
edit trước đó); viết lại tests/test_monitoring_page_size.py cho đúng
options/phân trang mới.

Cập nhật Task Tracking Template.xlsx — điền Bước tái hiện/Root Cause/
Cách xử lý/Regression Test/Evidence cho DF-002, DF-003, DF-005, DF-006
và dòng "Cowork/GraphRAG chỉ hiện khi chọn project" (qua Excel COM, để
không làm mất shape/data-validation nâng cao khác trong file).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:07:13 +09:00
duylh19andClaude Opus 5 57af508971 fix(sandbox): chặn mạng thật sự cho mọi tool NETWORK của agent
CI / test (pull_request) Canceled after 0s
block_network trước đây chỉ được run_command đọc tới, nên fetch_url,
jira_search, jira_get_issue và install_package vẫn ra internet bình thường
trong khi Monitoring báo Mạng - Bị chặn. Thêm cổng chặn ngay đầu bốn tool
đó, trước mọi lời gọi mạng, kèm test hồi quy và guardrail theo
BUILT_IN_CAPABILITIES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 08:33:26 +09:00
duylh19andClaude Opus 5 d0df96c726 fix(workspace): Cowork/GraphRAG chỉ hiện khi đã chọn project
Mở app lên là hai mục đã hiện sẵn dù người dùng chưa bấm chọn project nào.
Cổng này có hai mặt và cả hai đều hở:

1. refresh() mặc định row_to_select = 0, nên lúc mở app (_current_id rỗng)
   danh sách tự chọn hộ project đầu tiên -> _update_tab_visibility(True).
   Cùng đường đó còn làm app nhảy sang project khác ngay sau khi xoá project
   đang mở. Đổi mặc định thành -1; có keep khớp thì vẫn giữ đúng dòng cũ.

2. NavRailMixin._rebuild_nav dựng hàng bị cổng đóng ở dạng mờ kèm tooltip
   ("shown instead of hidden") nên chúng vẫn nằm trên menu trái. Nay bỏ hẳn
   hàng cho tới khi chọn project.

Mặt 1 làm selected_project_id() trả rỗng, khiến bộ chọn project trên rail rơi
về mục 0 và chỉ vào project đầu danh sách trong khi cổng vẫn đóng — hai chỗ
nói hai đằng. Thêm mục "Chọn project…" (khoá app.nav.pick_project, khác
no_project: đã có project, chỉ chưa chọn) để rail nói đúng trạng thái.

tools/check_nav.py và một bài trong test_startup_landing.py đang chốt thiết
kế cũ; cập nhật để chốt hành vi mới. Bài startup giữ nguyên từng assert, chỉ
đổi cách mở cổng từ cửa sau setTabVisible sang chọn project thật.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:32:08 +09:00
duylh19andClaude Opus 5 10799dc67b feat(settings): bỏ khoá mật khẩu ở Sandbox Security Layer
Bốn công tắc của nhóm này (xác nhận lệnh, chặn mạng, bật lớp bảo mật agent,
AI kiểm tra lệnh) dựng ra ở trạng thái setEnabled(False) và chỉ mở khi nhập
đúng mật khẩu. Theo yêu cầu, bỏ hẳn bước đó: form luôn bật/tắt được.

Gỡ ô nhập, nút Mở khoá, nhãn trạng thái khoá, _sandbox_unlock() và
_sandbox_password_matches(); dọn 10 khoá i18n thành chết và 2 field trong
tools/check_dialogs.py. Giữ nguyên agent_security.sandbox_pw ở config.py —
yêu cầu chỉ nói tới màn hình, không nói tới tầng cấu hình.

Khoá này vốn không phải rào bảo mật: docstring của _sandbox_unlock() đã tự
ghi "khoá phía giao diện để chặn bấm nhầm ... KHÔNG phải cơ chế bảo mật
thật". Rào thật nằm ở sandbox lúc chạy lệnh. Vùng này thuộc diện SECURITY.md
yêu cầu Cowork Team soát thêm.

10 bài test cũ (SEC-20260907-01) chốt các đường không được mở khoá nay mất
đối tượng kiểm, thay bằng 12 bài chốt hành vi mới: bốn công tắc sửa được
ngay, không còn widget mật khẩu, kèm guardrail quét mã nguồn chặn khoá lại.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:31:49 +09:00
31 changed files with 95 additions and 946 deletions
Binary file not shown.
-9
View File
@@ -527,15 +527,6 @@ def run_cowork(
preview = {"kind": "info", "title": name, "text": str(args)}
emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args,
"preview": preview})
if ctx.block_network:
result = {"ok": False, "output": (
f"{name}: network access is blocked by the Sandbox Security Layer "
'("Block network for agent-run commands" is on in Settings).')}
emit({"type": "tool_result", "id": tc_id, "name": name,
"ok": False, "output": result["output"]})
messages.append({"role": "tool", "tool_call_id": tc_id, "name": name,
"content": result["output"]})
continue
# R05-T04: MCP/connector tools used to run with NO permission
# check at all — this is what closes that gap. Same policy,
# same gate object as the built-in tools below.
+1 -6
View File
@@ -327,12 +327,7 @@ def run_code(
else:
emit({"type": "tool_start", "id": tc_id, "name": name})
if is_extra and extra_executor is not None:
if ctx.block_network:
result = {"ok": False, "output": (
f"{name}: network access is blocked by the Sandbox Security Layer "
'("Block network for agent-run commands" is on in Settings).')}
else:
result = extra_executor(name, args)
result = extra_executor(name, args)
else:
def on_output(line: str, _id=tc_id, _name=name) -> None:
emit({"type": "tool_output", "id": _id, "name": _name, "delta": line})
+8 -19
View File
@@ -94,28 +94,17 @@ def find_input_files(folder: Path, exts: set[str] | None = None,
capped at ``max_files`` (0 = unlimited), ``total_matched`` is the count
before that cap, so a caller can report how many were skipped."""
exts = exts or INPUT_EXTS
# Do not sort an unbounded recursive tree merely to return a small prefix.
# The caller receives a stable lexical order for the bounded result, while
# traversal stops as soon as the configured file budget is reached.
files: list[Path] = []
total = 0
try:
for f in folder.rglob("*"):
if not f.is_file():
continue
try:
relative = f.relative_to(folder)
except ValueError:
continue
if any(part.startswith(".") for part in relative.parts) or f.suffix.lower() not in exts:
continue
total += 1
if max_files <= 0 or len(files) < max_files:
files.append(f)
matched = sorted(
f for f in folder.rglob("*")
if f.is_file()
and not any(part.startswith(".") for part in f.relative_to(folder).parts)
and f.suffix.lower() in exts
)
except OSError:
return [], 0
files.sort(key=lambda p: str(p).lower())
return files, total
files = matched if max_files <= 0 else matched[:max_files]
return files, len(matched)
def find_soffice() -> str | None:
+1 -30
View File
@@ -16,17 +16,6 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List
from ..performance import span
_LIST_CACHE: dict[tuple[str, str, int], List[Dict[str, Any]]] = {}
def _invalidate_history_cache(directory: Path) -> None:
prefix = str(Path(directory).resolve())
for key in list(_LIST_CACHE):
if key[0] == prefix:
_LIST_CACHE.pop(key, None)
def new_session_id() -> str:
"""Id phiên mới theo mốc thời gian, chính xác tới mili giây."""
@@ -89,7 +78,6 @@ def save_conversation(
# R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py.
from ..infrastructure.persistence.json.atomic_write import write_json
write_json(path, payload)
_invalidate_history_cache(directory)
return path
@@ -97,7 +85,6 @@ def delete_conversation(path) -> None:
"""Xoá file hội thoại; không có thì bỏ qua."""
try:
Path(path).unlink()
_invalidate_history_cache(Path(path).parent)
except OSError:
pass
@@ -109,7 +96,6 @@ def rename_conversation(path, new_title: str) -> None:
data = load_conversation(path)
data["title"] = new_title
write_json(Path(path), data)
_invalidate_history_cache(Path(path).parent)
def set_pinned(path, pinned: bool) -> None:
@@ -119,7 +105,6 @@ def set_pinned(path, pinned: bool) -> None:
data = load_conversation(path)
data["pinned"] = bool(pinned)
write_json(Path(path), data)
_invalidate_history_cache(Path(path).parent)
def load_conversation(path: Path) -> Dict[str, Any]:
@@ -212,16 +197,8 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis
if not directory or not directory.exists():
return []
q = (query or "").strip().lower()
try:
cache_key = (str(directory.resolve()), q, directory.stat().st_mtime_ns)
except OSError:
return []
cached = _LIST_CACHE.get(cache_key)
if cached is not None:
return [dict(item) for item in cached]
items: List[Dict[str, Any]] = []
with span("history.list", query=bool(q)):
for path in directory.glob("*.json"):
for path in directory.glob("*.json"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
@@ -244,10 +221,4 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis
})
# pinned first, then most recent
items.sort(key=lambda d: (not d["pinned"], -d["mtime"]))
_LIST_CACHE[cache_key] = [dict(item) for item in items]
# Keep this bounded; old directory signatures become unreachable after a
# write and should not grow process memory forever.
if len(_LIST_CACHE) > 256:
for old in list(_LIST_CACHE)[:64]:
_LIST_CACHE.pop(old, None)
return items
-48
View File
@@ -24,7 +24,6 @@ project — nothing about it is special-cased in the UI.
from __future__ import annotations
import json
import os
import re
from dataclasses import asdict, dataclass, field
from datetime import datetime
@@ -83,53 +82,6 @@ class Project:
return (base or WORKSPACES_DIR) / self.project_id
def _norm_dir(path) -> str:
"""Đường dẫn đã chuẩn hoá để đem ra so sánh.
Bung ``~``, đưa về tuyệt đối, rồi ``normcase`` — trên Windows thì
``D:/Work`` và ``d:/work`` là cùng một thư mục, nên so chuỗi thô sẽ
cho hai project chiếm chung một chỗ mà không ai biết.
"""
return os.path.normcase(os.path.abspath(os.path.expanduser(str(path))))
def _cham_nhau(a: str, b: str) -> bool:
"""Hai thư mục đã chuẩn hoá có chạm nhau không: trùng, hoặc lồng nhau.
Lồng nhau cũng tính, vì lý do tồn tại của sandbox là "agent của project này
không bao giờ chạm được file của project kia" (xem docstring đầu module).
Đứng ở thư mục cha thì đọc/ghi được toàn bộ thư mục con, nên cha-con vẫn là
chạm nhau dù hai đường dẫn không giống nhau.
"""
return a == b or a.startswith(b + os.sep) or b.startswith(a + os.sep)
def folder_conflict(path, *, ignore_id: str = "",
directory: Path = None) -> Optional[Project]:
"""Project khác đang chiếm ``path``, hoặc ``None`` nếu chưa ai chiếm.
Mỗi thư mục chỉ được thuộc về một project: thư mục làm việc vừa là sandbox
vừa là kho kiến thức dùng chung của project, nên hai project dùng chung một
thư mục là đọc lẫn dữ liệu của nhau.
So theo thư mục THỰC SỰ đang dùng (``workspace_dir()``), không phải theo
``output_dir``: project chưa đặt thư mục riêng vẫn đang chiếm thư mục quản
lý sẵn của nó, và chính thư mục đó là thứ hay bị chọn nhầm.
``ignore_id`` là project đang sửa — giữ nguyên thư mục của chính nó thì
không phải là trùng.
"""
if not str(path).strip():
return None
muon = _norm_dir(path)
for project in list_projects(directory):
if project.project_id == ignore_id:
continue
if _cham_nhau(muon, _norm_dir(project.workspace_dir())):
return project
return None
def _starter_project() -> Project:
"""An ordinary (deletable, renamable) project seeded when the projects
folder is empty, so the app always opens with somewhere to chat."""
-23
View File
@@ -1,23 +0,0 @@
Source HEAD: db80289 (preserved)
Branch: perf/fsg-performance
Baseline: app import 1517ms; config 934ms; MainWindow 1742ms (offscreen, local machine).
Packets completed:
- P0: measured constructor with cProfile; dominant cost was provider model discovery (~0.7s network worker) and eager Workspace composition.
- P2: cache history listing by directory mtime/query and coalesce sidebar refresh bursts.
- P3: batch streaming Markdown/layout renders at 40ms; final content remains intact.
- P4: bounded attachment discovery avoids sorting a full recursive tree when a cap is set.
- P5: instrument monitoring log refresh; existing 30-day bounded window retained.
- P6: defer provider model discovery to the first Qt event-loop turn.
After: config 452ms; MainWindow 599ms in the same offscreen smoke benchmark (discovery no longer blocks construction).
Streaming render count is now bounded by batch cadence rather than token count.
Representative history benchmark: 1,000 files 383.6ms cold / 0.8ms cached on this machine.
Relevant commits: c5cb258 (perf: defer discovery and reduce UI refresh work).
Remaining bottleneck: eager Workspace/Co4E/Folder widget construction and import-time PySide6 overhead.
Closure pass (starting HEAD 58b5220): Workspace now keeps Co4E, Folder, and GraphRAG as tab placeholders and creates each once on first selection. MainWindow benchmark: 357.6ms; first opens Co4E 143.1ms, Folder 148.0ms, GraphRAG 270.6ms; repeat opens 0.0–2.4ms. Focused lazy navigation/project tests: 15 passed. Pytest temp failures were ACL/path setup issues, not production assertions; a pre-created writable repository-local temp base allowed the focused gates to pass.
Remaining startup cost is base PySide6/application import and eager Cowork shell; further lazy work is not justified without broader architectural risk.
Performance initiative status: closed for this pass.
-11
View File
@@ -57,17 +57,6 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Another project is already called \"{name}\". Project names must be unique — the list shows nothing but the name, so two of them cannot be told apart.",
"ja": "「{name}」という名前のプロジェクトが既にあります。一覧には名前しか出ないため、同じ名前が二つあると区別できません。",
"vi": "Đã có project khác tên \"{name}\". Tên project phải khác nhau — danh sách chỉ hiện tên, trùng tên là không phân biệt được."},
"workspace.folder_taken_title": {
"en": "Folder already used", "ja": "フォルダーが重複しています",
"vi": "Thư mục đã được dùng"},
"workspace.folder_taken_body": {
"en": "Project \"{name}\" already works in {folder}. One folder belongs to one project only — the folder is that project's sandbox and shared knowledge, so sharing it lets two projects read and overwrite each other's files. Pick another folder.",
"ja": "プロジェクト「{name}」が既に {folder} を使用しています。フォルダーは 1 つのプロジェクト専用です — フォルダーはそのプロジェクトのサンドボックス兼共有ナレッジなので、共有すると互いのファイルを読み書きしてしまいます。別のフォルダーを選んでください。",
"vi": "Project \"{name}\" đang làm việc trong {folder}. Mỗi thư mục chỉ thuộc về một project — thư mục vừa là sandbox vừa là kho kiến thức chung của project đó, dùng chung là hai project đọc và ghi đè file của nhau. Hãy chọn thư mục khác."},
"workspace.folder_shared_warning": {
"en": "⚠ This folder is also used by project \"{name}\". One folder belongs to one project only — pick another folder for one of them.",
"ja": "⚠ このフォルダーはプロジェクト「{name}」でも使われています。フォルダーは 1 つのプロジェクト専用です — どちらかに別のフォルダーを指定してください。",
"vi": "⚠ Thư mục này đang được project \"{name}\" dùng chung. Mỗi thư mục chỉ thuộc về một project — hãy đổi thư mục cho một trong hai."},
"workspace.instructions_placeholder": {
"en": "e.g. \"All answers in Vietnamese. We are building the X reporting tool; always follow the naming rules …\"",
"ja": "例:「回答はすべて日本語で。X レポートツールを開発中。命名規則に従うこと …」",
-31
View File
@@ -1,31 +0,0 @@
"""Tiny opt-in performance tracing helpers.
Tracing is disabled by default and emits only timings/counts, never prompts,
credentials, file contents, or provider payloads.
"""
from __future__ import annotations
import logging
import os
import time
from contextlib import contextmanager
_LOG = logging.getLogger("cowork.performance")
def enabled() -> bool:
return os.environ.get("COWORK_PERF_TRACE", "").strip().lower() in {"1", "true", "yes"}
@contextmanager
def span(name: str, **fields):
if not enabled():
yield
return
started = time.perf_counter()
try:
yield
finally:
elapsed = (time.perf_counter() - started) * 1000.0
safe = " ".join(f"{k}={v}" for k, v in fields.items())
_LOG.info("perf %s %.1fms%s", name, elapsed, f" {safe}" if safe else "")
+4 -17
View File
@@ -115,23 +115,10 @@ class ChatAgentsMixin:
if err:
self.status_message.emit(tr("chatpanel.agent_list_error", err=err))
# Model discovery can involve a provider/network request. Constructing
# the chat panel during startup must not wait for it; schedule it after
# the first event-loop turn so the initial shell can paint immediately.
from PySide6.QtCore import QTimer
if getattr(self, "_agent_refresh_pending", False):
return
self._agent_refresh_pending = True
def start_worker() -> None:
self._agent_refresh_pending = False
w = AgentWorker(job)
w.finished_ok.connect(done)
self._agent_worker = w
w.start()
QTimer.singleShot(0, start_worker)
w = AgentWorker(job)
w.finished_ok.connect(done)
self._agent_worker = w
w.start()
def _populate_agents(self, models, keep: str) -> None:
"""Đổ danh sách vào bộ chọn Agent.
+2 -11
View File
@@ -51,8 +51,6 @@ class MessageBubble(QFrame):
super().__init__()
self.role = role
self._text = ""
self._stream_pending = False
self._render_count = 0
self._collapsible = collapsible
self._title = title
self._head = None
@@ -166,20 +164,12 @@ class MessageBubble(QFrame):
def append_delta(self, delta: str) -> None:
"""Nối thêm một mẩu văn bản đang phát dần từ model rồi vẽ lại dạng markdown."""
self._text += delta
if not self._stream_pending:
self._stream_pending = True
QTimer.singleShot(40, self.flush_stream)
def flush_stream(self) -> None:
if self._stream_pending:
self._stream_pending = False
self.set_markdown(self._text)
self.set_markdown(self._text)
def set_markdown(self, text: str) -> None:
"""Đặt toàn bộ nội dung, hiển thị dạng markdown, rồi co giãn lại chiều cao."""
self._text = text
self.body.setMarkdown(text)
self._render_count += 1
self._autosize()
if self._collapsible:
self._update_head()
@@ -403,3 +393,4 @@ class ChatView(QScrollArea):
ChatHistoryWidget = ChatView
__all__ = ["ChatView", "ChatHistoryWidget", "MessageBubble"]
+1 -1
View File
@@ -237,7 +237,7 @@ class GraphRenderer(QWidget):
# ---- prewarm / scan lifecycle -------------------------------------------------- #
def prewarm(self) -> None:
"""Pay for the graph view before it is clicked on, not during."""
if self.web is not None:
if not HAS_WEB_ENGINE or self.web is not None:
return
self._ensure_web()
if self._graph is None and self.path_edit.text().strip():
@@ -219,6 +219,12 @@ class StructureGraphView(QWidget):
"""Dựng sẵn khung đồ thị trước khi người dùng bấm vào, để lần mở đầu không giật."""
self.renderer.prewarm()
def refresh_project_list(self) -> None:
"""Project khác vừa được tạo/sửa/xoá/đổi tên — làm mới danh sách trong
bộ chọn project của renderer (bộ chọn KHÔNG tự nạp lại khi project
thay đổi ở màn khác, chỉ khi ``set_project`` được gọi)."""
self.renderer._refresh_project_combo()
def hideEvent(self, e): # noqa: N802
# Leaving the GraphRAG tab → drop the temporary extracted info.
"""Rời màn GraphRAG thì xoá phần trích xuất tạm của khung hỏi-đáp."""
+6 -8
View File
@@ -19,7 +19,6 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QTabWidget, QVBoxLayout, QWid
from ...core import audit_log
from ...i18n import on_language_changed, tr
from ...state import AppContext
from ...performance import span
from .tabs.action_logs_tab import ActionLogsTab
from .tabs.agent_status_tab import AgentStatusTab
from .tabs.mcp_tab import McpTab
@@ -283,13 +282,12 @@ class MonitoringTab(QWidget):
"""
start = date.today() - timedelta(days=_LOG_WINDOW_DAYS)
shared_dir = self.ctx.config.shared_dir
with span("monitoring.load_events", window_days=_LOG_WINDOW_DAYS):
if shared_dir:
from ...core import telemetry_shared
shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start)
if shared_events:
return shared_events
return audit_log.load_events(start=start)
if shared_dir:
from ...core import telemetry_shared
shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start)
if shared_events:
return shared_events
return audit_log.load_events(start=start)
def _apply_events_to_event_tabs(self, events: List[dict]) -> None:
"""Filters the ALREADY-LOADED event list (see ``_load_events`` — one
@@ -216,8 +216,7 @@ class ToolsAdminTab(QWidget):
result. Respects the fetch_url toggle: when web access is OFF the agent
cannot reach the internet, so the test reports that instead of probing."""
disabled = ("fetch_url" in self.ctx.config.tools_disabled
or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True))
or bool(self.ctx.config.agent_security.get("block_network", False)))
or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True)))
if disabled:
self.test_internet_status.setText(tr("tools_admin.internet_disabled"))
self.test_internet_status.setStyleSheet("color: #c00;")
@@ -27,14 +27,7 @@ def _frozen_onefile() -> bool:
return True
# QtWebEngine is noisy and unreliable on the macOS runtime we support (GPU/
# helper-process failures leave the stacked view blank). The native Qt graph is
# already available and avoids that failure path entirely.
HAS_WEB_ENGINE = False
try: # WebEngine + WebChannel are optional PySide6 add-ons
if sys.platform == "darwin":
raise ImportError("use native graph renderer on macOS")
from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401
from PySide6.QtWebChannel import QWebChannel # noqa: F401
HAS_WEB_ENGINE = not _frozen_onefile()
+5 -2
View File
@@ -32,7 +32,9 @@ from .rail_metrics import _NAV_MIN_WIDTH
from .tray_manager import TrayManager
from ...state import AppContext
from ...core.task_scheduler import TaskScheduler
from ...ui.cowork_tab import CoworkTab
from ...ui.sidebar import HistorySidebar
from ..graph.structure_graph_view import StructureGraphView
from ...ui.workspace_tab import WorkspaceTab
@@ -109,9 +111,10 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
# Workspace screen (per selected project). GraphRAG's heavy
# QtWebEngine is still built lazily on first display
# (StructureGraphView._ensure_web).
from ...ui.cowork_tab import CoworkTab
self.cowork = CoworkTab(ctx)
self.structure = None
self.structure = StructureGraphView(ctx)
self.structure.status_message.connect(self.statusBar().showMessage)
self.cowork.output_changed.connect(self.structure.schedule_rescan)
self.cowork.status_message.connect(self.statusBar().showMessage)
# Refresh History (list + running markers + current highlight) whenever a
# conversation is created/updated or a turn finishes.
+2 -3
View File
@@ -142,11 +142,10 @@ class PageRegistryMixin:
Vệt sáng trên thanh menu cũng cập nhật ở đây, để nó đi theo NỘI DUNG chứ
không theo thứ vừa được bấm.
"""
was_page = self.pages.currentIndex()
self._ensure_page(page) # build lazy page on first visit
self.pages.setCurrentIndex(page)
if page == self._ROW_WORKSPACE and was_page != page:
self.workspace.refresh() # refresh only when entering Workspace
if page == self._ROW_WORKSPACE:
self.workspace.refresh() # re-list projects + threads on entry
widget = self._page_widgets[page]
if sub is not None and hasattr(widget, "select_subtab"):
# Enforce the project gate here rather than at each entry point. A
+3 -2
View File
@@ -19,8 +19,9 @@ _NAV_ROW_GAP = 6
# Không đặt bằng ``margin`` trong QSS: margin của stylesheet được vẽ BÊN TRONG
# hộp của widget, mà nút này lại bị ``_rebuild_nav`` ghim đúng chiều cao một
# dòng menu — nên margin không mua được một pixel khoảng cách nào.
# Settings dùng cùng nhịp hàng với Dashboard và Monitoring.
_NAV_SETTINGS_GAP = 0
# 10 -> 4: đủ để Cài đặt không dính vào nhóm Dashboard/Giám sát, nhưng không
# rộng đến mức trông như hai khu tách rời.
_NAV_SETTINGS_GAP = 4
# 132 -> 232: o 132px nhan "Cuoc tro chuyen moi" bi cat mat chu. San phai du
# rong cho nhan DAI NHAT tren thanh, khong phai cho nhan trung binh.
_NAV_MIN_WIDTH = 232
+2 -10
View File
@@ -42,14 +42,7 @@ class SessionEventsMixin:
self.sidebar.refresh()
self._refresh_rail_recents() # the rail shortcut follows the panel
# Coalesce bursts from turn/tool/history signals into one sidebar read.
timer = getattr(self, "_history_refresh_timer", None)
if timer is None:
timer = QTimer(self)
timer.setSingleShot(True)
timer.timeout.connect(_do)
self._history_refresh_timer = timer
timer.start(0)
QTimer.singleShot(0, _do)
def _on_scheduled_task_done(self, task_id: str, ok: bool) -> None:
"""Desktop notification for a finished scheduled task (toast always,
tray balloon when the window isn't focused), then refresh History —
@@ -113,5 +106,4 @@ class SessionEventsMixin:
"""Project được tạo/sửa/xoá: gom nhóm lại cột lịch sử và cập nhật nhãn thư mục."""
self.sidebar.refresh() # History regroups by project
self.cowork._apply_output_folder_label() # project may have been renamed
if getattr(self, "structure", None) is not None:
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too
self.structure.refresh_project_list() # GraphRAG's project lock list follows too
+6 -12
View File
@@ -32,10 +32,10 @@ class TopBarMixin:
``_build_account_row`` ngay bên dưới, chỉ khác chỗ đặt trên màn hình.
"""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStyle
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton
from ...i18n import tr
from ...ui.icons import icon as _icon
from .rail_metrics import _NAV_ROW_INSET, _NAV_SETTINGS_GAP
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET, _NAV_SETTINGS_GAP
# Bottom-pinned group: the places you visit occasionally, kept out of the
# way of the ones you live in. A hairline (styled via #navrailBottom in
@@ -59,17 +59,11 @@ class TopBarMixin:
# that number. Adding it again here made the row taller than the button
# (28 wanted, 20 given), which both clipped the icon and pushed the text
# 8px below an even pitch with Dashboard / Giám sát.
srow.setContentsMargins(_NAV_ROW_INSET + 2, 0, 8, 0)
# Khe giữa icon và chữ phải là khe của STYLE, không phải nhịp riêng của
# rail: delegate của cây vẽ chữ ngay sau hộp icon, cách đúng
# ``PM_FocusFrameHMargin + 1``. Đặt ``_NAV_ROW_GAP + 4`` (=10) ở đây cộng
# với 10px lề trái và hộp icon 22px thành 42 — trong khi Dashboard /
# Giám sát đặt chữ ở 35, nên hàng Cài đặt thụt phải 7px.
srow.setSpacing(
self.nav_bottom.style().pixelMetric(QStyle.PM_FocusFrameHMargin) + 1)
srow.setContentsMargins(_NAV_ROW_INSET, 0, 8, 0)
srow.setSpacing(_NAV_ROW_GAP)
self._nav_settings_icon = QLabel()
self._nav_settings_icon.setPixmap(_icon("gear").pixmap(16, 16))
self._nav_settings_icon.setFixedSize(22, 16)
self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16))
self._nav_settings_icon.setFixedSize(16, 16)
self._nav_settings_text = QLabel(tr("app.settings"))
srow.addWidget(self._nav_settings_icon)
srow.addWidget(self._nav_settings_text)
+8 -25
View File
@@ -52,7 +52,7 @@ class ProjectRow(QWidget):
lay = QVBoxLayout(self)
lay.setContentsMargins(6, 4, 6, 4)
lay.setSpacing(3)
lay.setSpacing(0)
self.title_label = QLabel(name)
self.counts_label = QLabel()
self.counts_label.setObjectName("hint")
@@ -90,7 +90,6 @@ def _row_layout_of(widget: QWidget) -> QLayout | None:
return None
class ProjectEditingMixin:
"""Danh sách project + CRUD + chế độ sửa. Trộn vào ``WorkspaceTab``.
@@ -134,17 +133,10 @@ class ProjectEditingMixin:
# 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 -------------------------------------------
@@ -161,14 +153,15 @@ class ProjectEditingMixin:
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
has_project = bool(getattr(self, "_current_id", ""))
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._browse_btn.setEnabled(on and has_project)
self._save_btn.setEnabled(on and has_project)
self._edit_btn.setEnabled(not on and has_project)
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
@@ -178,25 +171,15 @@ class ProjectEditingMixin:
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 "Sửa project" và "Lưu project" ngoài sub-tab Project.
Ẩ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ở.
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.
"""
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:
@@ -1,113 +0,0 @@
"""Luật "mỗi thư mục làm việc chỉ thuộc về MỘT project".
Tách khỏi ``project_editing.py`` chứ không nhét thêm vào đó: file kia đã gom
bốn tính năng và thêm luật này là chạm trần 400 dòng của
``scripts/check_loc.py``. Đây cũng là một mối quan tâm riêng — nó không nói về
việc *sửa* một project mà về việc hai project không được giẫm lên nhau.
Luật có hai nửa, cố ý không đối xứng:
* **Chặn lúc CHỌN.** Ba nơi đặt được thư mục làm việc (nút "Đổi" ở màn Project,
thư mục cloud, nút chọn thư mục trong tab Cowork) đều đi qua
:func:`folder_taken_blocked`, để cả ba chặn giống hệt nhau. Không chặn ở
"Lưu project": nút đó chỉ ghi tên/mô tả/chỉ dẫn, chặn ở đó sẽ khoá luôn việc
đổi tên một project lỡ đang trùng thư mục.
* **Cảnh báo cho cái ĐANG sai.** Dữ liệu cũ có thể đã có hai project trỏ vào
cùng một thư mục, mà nửa trên chỉ chặn từ nay trở đi. Nhãn dưới ô "Thư mục
làm việc" nói ra điều đó và để người dùng tự đổi — sửa hộ là tự ý đụng vào
dữ liệu của họ.
Phép so trùng nằm ở ``core/projects.py::folder_conflict`` (thuần, không Qt).
"""
from __future__ import annotations
from PySide6.QtWidgets import QLabel, QLayout, QMessageBox, QWidget
from ...i18n import tr
from .project_editing import _row_layout_of
def _layout_chua(layout: QLayout, con: QLayout) -> "tuple | None":
"""``(layout_cha, vị_trí)`` của ``con`` bên trong ``layout``, duyệt đệ quy."""
for i in range(layout.count()):
item = layout.itemAt(i)
ben_trong = item.layout()
if ben_trong is con:
return layout, i
if ben_trong is not None:
tim = _layout_chua(ben_trong, con)
if tim is not None:
return tim
return None
def folder_taken_blocked(parent: QWidget, path: str, ignore_id: str) -> bool:
"""``True`` nếu ``path`` đã thuộc project khác — và đã báo cho người dùng.
Dùng chung cho cả ba nơi đặt được thư mục làm việc (nút "Đổi" ở màn
Project, thư mục cloud, và nút chọn thư mục trong tab Cowork), để cả ba
chặn giống hệt nhau thay vì mỗi nơi tự nghĩ ra một luật.
Chặn ở lúc CHỌN chứ không ở lúc Lưu: "Lưu project" chỉ ghi tên, mô tả và
chỉ dẫn — chặn ở đó sẽ khoá luôn việc đổi tên một project lỡ đang trùng
thư mục, tức phạt người dùng vì một trạng thái họ chưa kịp sửa.
"""
from ...core.projects import folder_conflict
khac = folder_conflict(path, ignore_id=ignore_id)
if khac is None:
return False
QMessageBox.warning(parent, tr("workspace.folder_taken_title"),
tr("workspace.folder_taken_body", name=khac.name,
folder=str(khac.workspace_dir())))
return True
class ProjectFolderRuleMixin:
"""Nửa giao diện của luật. Trộn vào ``WorkspaceTab``."""
def install_project_folder_rule(self) -> None:
"""Dựng nhãn cảnh báo và nối nó vào việc đổi project.
Gọi từ ``install_project_editing``, tức sau khi form đã dựng xong.
"""
self._folder_warn_lbl = QLabel()
self._folder_warn_lbl.setObjectName("warning") # màu lấy từ theme/
self._folder_warn_lbl.setWordWrap(True)
self._folder_warn_lbl.hide()
self._gan_nhan_canh_bao_thu_muc()
self.project_selected.connect(self._sync_folder_warning)
def _gan_nhan_canh_bao_thu_muc(self) -> None:
"""Chèn nhãn cảnh báo ngay DƯỚI hàng chứa ô Thư mục làm việc.
Chèn từ đây thay vì thêm dòng vào ``_build_project_tab``: file
``ui/workspace_tab.py`` đang vượt trần của ``scripts/check_loc.py``,
nên mọi dòng mới đều phải tránh nó (cùng lý do nút "Sửa project" được
chèn bằng ``_row_layout_of``).
"""
hang = _row_layout_of(self.folder_lbl)
cha = self.folder_lbl.parentWidget()
if hang is None or cha is None or cha.layout() is None:
return
tim = _layout_chua(cha.layout(), hang)
if tim is None:
return
layout, vi_tri = tim
layout.insertWidget(vi_tri + 1, self._folder_warn_lbl)
def _sync_folder_warning(self, *_a) -> None:
"""Hiện/ẩn cảnh báo "thư mục đang dùng chung" theo project đang mở."""
from ...core.projects import folder_conflict, load_project
pid = getattr(self, "_current_id", "")
project = load_project(pid) if pid else None
khac = (folder_conflict(project.workspace_dir(), ignore_id=pid)
if project is not None else None)
if khac is None:
self._folder_warn_lbl.hide()
return
self._folder_warn_lbl.setText(
tr("workspace.folder_shared_warning", name=khac.name))
self._folder_warn_lbl.show()
-4
View File
@@ -233,10 +233,6 @@ class AppContext:
connections across calls/turns (spawning a subprocess per turn would
be slow and wasteful). A server/connector that fails to connect is
skipped, not a hard failure for the turn."""
# Sandbox Security Layer blocks agent-owned network connectors before
# they can spawn a server or issue a REST request.
if self.config.agent_security.get("block_network", False):
return [], None
# Master switch (Monitoring → Tools → Connector): when the admin turns
# "Connect to external" off, the agent connects to NO external
# connectors/MCP at all — no subprocesses spawned, no REST calls.
@@ -55,6 +55,25 @@ def test_structure_graph_view_builds(ctx):
assert view.qa is not None
def test_refresh_project_list_forwards_to_renderer(ctx):
"""Crash bug: ``presentation/shell/session_events.py::_on_projects_changed``
called ``self.structure._refresh_project_combo()`` — a method that only
ever existed on ``GraphRenderer``, not on ``StructureGraphView`` itself —
so creating/renaming/deleting a project (anywhere in the app) raised
``AttributeError`` and crashed. ``refresh_project_list()`` is the public
forwarding method callers must use instead (matching ``schedule_rescan``/
``set_project``/``prewarm``'s existing forwarding pattern)."""
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
view = StructureGraphView(ctx)
assert not hasattr(view, "_refresh_project_combo")
combo = view.renderer.project_combo
before = combo.count()
view.refresh_project_list() # must not raise
assert combo.count() == before # re-populated from the same project list, same size
def test_render_populates_the_scene_and_emits_graph_rendered(ctx, tmp_path):
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
-135
View File
@@ -1,135 +0,0 @@
"""Ba hàng cuối thanh rail phải thẳng hàng: Dashboard, Giám sát, Cài đặt.
Dashboard và Giám sát là hàng của ``QTreeWidget`` (``#navrailBottom``), còn Cài
đặt là một ``QPushButton`` tự dựng lấy icon + chữ trong ``top_bar.py``. Hai cách
vẽ khác nhau nên không có gì tự giữ cho chúng thẳng hàng — phải chốt bằng test.
Lần lệch gần nhất: ``srow.setSpacing(_NAV_ROW_GAP + 4)`` (=10) cộng với 10px lề
trái và hộp icon 22px đặt chữ "Cài đặt" ở x=42, trong khi delegate của cây đặt
chữ ở x=35 — thụt phải 7px, thấy rõ bằng mắt trên thanh rail.
Cách đo: render thanh rail ra ảnh rồi tìm cột mực đầu tiên, vì đó đúng là thứ
người dùng nhìn thấy. Trước khi đo, ba hàng được ép về **cùng một icon và cùng
một chữ** — chữ khác nhau thì phần nhô trái của glyph đầu tiên ("D" so với "G"
so với "C") đã lệch nhau vài pixel, và bài test sẽ đo hình dáng chữ chứ không
đo bố cục.
"""
from __future__ import annotations
import os
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
#: Bỏ qua cột mực nằm sát mép trái: hàng đang được chọn có thêm vạch
#: ``border-left: 2px solid $accent`` (theme/qss.py), không phải icon.
_BO_QUA_MEP_TRAI = 5
@pytest.fixture(scope="module")
def window(qapp, tmp_path_factory):
"""Cửa sổ thật, có nạp stylesheet đúng như ``app.py`` làm.
Không nạp thì ``QTreeWidget::item { padding: 6px 10px }`` không áp, hàng của
cây thụt về 0 còn nút Cài đặt vẫn giữ lề 10px của layout — bài test sẽ đỏ vì
thiếu theme chứ không vì lỗi bố cục.
"""
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
from cowork_local.theme import set_active_theme, stylesheet
config_path = tmp_path_factory.mktemp("cfg") / "config.json"
build_config(config_path)
ctx = build_context(config_path)
css_cu = qapp.styleSheet()
set_active_theme(ctx.config.theme)
qapp.setStyleSheet(stylesheet(ctx.config.theme))
win = MainWindow(ctx)
win.resize(1280, 800)
win.show()
for _ in range(3):
qapp.processEvents()
yield win
win.close()
qapp.setStyleSheet(css_cu)
def _cum_muc(img, y0: int, y1: int):
"""Các cụm cột có mực trong dải ``[y0, y1)``, dạng ``[(x_đầu, x_cuối), ...]``.
Màu nền lấy ở cột sát mép phải cùng dòng y, nên hàng đang được tô nền chọn
vẫn so sánh đúng.
"""
w = img.width()
co_muc = [any(img.pixel(x, y) != img.pixel(w - 3, y) for y in range(y0, y1))
for x in range(w)]
cum, dau = [], None
for x, c in enumerate(co_muc):
if c and dau is None:
dau = x
elif not c and dau is not None:
if x - dau >= 2:
cum.append((dau, x - 1))
dau = None
if dau is not None:
cum.append((dau, w - 1))
return [c for c in cum if c[0] >= _BO_QUA_MEP_TRAI]
def _ep_ba_hang_ve_cung_hinh(window):
"""Cho ba hàng cùng icon và cùng chữ, để chỉ còn bố cục là khác biệt."""
from cowork_local.ui.icons import icon as _icon
for i in range(2):
it = window.nav_bottom.topLevelItem(i)
it.setIcon(0, _icon("gear"))
it.setText(0, "M")
window._nav_settings_icon.setPixmap(_icon("gear").pixmap(16, 16))
window._nav_settings_text.setText("M")
def _vi_tri_ba_hang(qapp, window):
"""``{tên hàng: (x_icon, x_chữ)}`` đo từ ảnh render của thanh rail."""
from PySide6.QtCore import QPoint
_ep_ba_hang_ve_cung_hinh(window)
for _ in range(3):
qapp.processEvents()
ref = window._nav_wrap
img = ref.grab().toImage()
ket = {}
for i, ten in ((0, "Dashboard"), (1, "Giám sát")):
it = window.nav_bottom.topLevelItem(i)
r = window.nav_bottom.visualItemRect(it)
y = window.nav_bottom.viewport().mapTo(ref, QPoint(0, r.y())).y()
cum = _cum_muc(img, y + 4, y + r.height() - 4)
assert len(cum) >= 2, f"{ten}: không tìm thấy đủ icon và chữ để đo"
ket[ten] = (cum[0][0], cum[1][0])
btn = window._nav_settings_btn
y = btn.mapTo(ref, QPoint(0, 0)).y()
cum = _cum_muc(img, y + 4, y + btn.height() - 4)
assert len(cum) >= 2, "Cài đặt: không tìm thấy đủ icon và chữ để đo"
ket["Cài đặt"] = (cum[0][0], cum[1][0])
return ket
def test_icon_ba_hang_thang_hang(qapp, window):
"""Icon của ba hàng phải bắt đầu ở cùng một cột."""
vi_tri = _vi_tri_ba_hang(qapp, window)
x = {ten: v[0] for ten, v in vi_tri.items()}
assert len(set(x.values())) == 1, f"icon lệch nhau: {x}"
def test_chu_ba_hang_thang_hang(qapp, window):
"""Chữ của ba hàng phải bắt đầu ở cùng một cột.
Đây là bài đỏ trước khi sửa: Cài đặt ở 42, hai hàng kia ở 35.
"""
vi_tri = _vi_tri_ba_hang(qapp, window)
x = {ten: v[1] for ten, v in vi_tri.items()}
assert len(set(x.values())) == 1, f"chữ lệch nhau: {x}"
-128
View File
@@ -1,128 +0,0 @@
"""Nút "Sửa project" phải sáng ngay khi đã có project đang mở.
Triệu chứng: mở app, chọn một project rồi vào sub-tab Project — biểu mẫu hiện
đủ tên, mô tả, thư mục làm việc, nhưng nút "Sửa project" vẫn mờ, không bấm được.
Nguyên nhân: trạng thái bật/mờ của ba nút chỉ được tính trong
``set_project_editable``, mà đổi project KHÔNG đi qua hàm đó — ``_load_current``
chỉ nạp lại biểu mẫu. ``_sync_project_buttons`` có chạy khi đổi sub-tab nhưng
ngày trước chỉ chỉnh ẩn/hiện, nên nút hiện ra mang theo trạng thái mờ tính từ
lúc dựng cửa sổ, khi chưa project nào được chọn.
Các bài dưới đây chốt cả bốn trạng thái: chưa có project → mờ; có project →
sáng; đi vòng qua sub-tab khác rồi quay lại → vẫn sáng; đang sửa dở → mờ lại
(nếu không thì "đang sửa" và "chưa sửa" trông giống hệt nhau).
Hai quy ước bắt buộc, lấy từ ``test_project_editing.py`` và
``test_project_gate_subtabs.py`` ngay cạnh:
* **Không tạo, không xoá project nào.** ``core/projects.py`` gắn
``PROJECTS_DIR`` vào ``~/.cowork_local`` THẬT, nên tạo project trong test là
ghi vào dữ liệu đang dùng của người chạy test. Trạng thái "đã có project"
được đặt thẳng vào ``_current_id`` — đúng biến mà ba nút đọc.
* **Một cửa sổ cho cả module.** Dựng ``MainWindow`` cho từng bài làm cả bộ
``tests/ui`` chết giữa chừng (Qt đổ stack trace, không phải test nào fail),
nên fixture ở đây là ``scope="module"`` và mỗi bài tự đặt trạng thái đầu vào
của mình.
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
_PID_GIA = "project-test-khong-ghi-dia"
@pytest.fixture(scope="module")
def ws(qapp, tmp_path_factory):
"""Màn Workspace của một MainWindow thật, dùng chung cho cả module."""
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
config_path = tmp_path_factory.mktemp("cfg") / "config.json"
build_config(config_path)
window = MainWindow(build_context(config_path))
yield window.workspace
window.close()
def _nap_project(qapp, ws, pid: str) -> None:
"""Đi đúng đường ``_load_current`` đi khi người dùng chọn một project.
Cố ý KHÔNG gọi ``set_project_editable``: chính vì ``_load_current`` không
gọi nó mà lỗi mới tồn tại. Gọi nó ở đây là bài test tự tay bật lại nút rồi
khẳng định nút đang bật — nó sẽ xanh cả trên bản chưa sửa.
``_project_editable`` đặt thẳng về ``False`` vì cửa sổ dùng chung cho cả
module: bài trước có thể đã để form ở chế độ sửa, mà nạp một project mới
thì form luôn ở chế độ chỉ-xem.
"""
ws.tabs.setCurrentIndex(ws._project_tab_idx)
ws._project_editable = False
ws._current_id = pid
ws.project_selected.emit(pid)
qapp.processEvents()
def _chua_co_project(qapp, ws) -> None:
"""Trạng thái chưa chọn project nào, đang ở sub-tab Project."""
_nap_project(qapp, ws, "")
def _mo_mot_project(qapp, ws) -> None:
"""Trạng thái đang mở một project."""
_nap_project(qapp, ws, _PID_GIA)
def test_chua_co_project_thi_nut_sua_mo(qapp, ws):
"""Chưa chọn project thì không có gì để sửa — đây là hành vi phải giữ."""
_chua_co_project(qapp, ws)
assert ws._edit_btn.isEnabled() is False
assert ws._edit_btn.isHidden() is True
def test_da_co_project_thi_nut_sua_sang(qapp, ws):
"""Bài đỏ trước khi sửa: nút hiện ra nhưng vẫn mờ."""
_mo_mot_project(qapp, ws)
assert ws._edit_btn.isHidden() is False, "nút phải hiện khi đã có project"
assert ws._edit_btn.isEnabled() is True, "nút phải bấm được khi đã có project"
def test_quay_lai_tab_project_thi_nut_van_sang(qapp, ws):
"""Đúng thao tác trong ảnh người dùng gửi: rời tab Project rồi quay lại."""
if ws.tabs.count() < 2:
pytest.skip("bản dựng này chỉ có một sub-tab, không đi vòng được")
_mo_mot_project(qapp, ws)
ws.tabs.setCurrentIndex(1 if ws._project_tab_idx == 0 else 0)
qapp.processEvents()
ws.tabs.setCurrentIndex(ws._project_tab_idx)
qapp.processEvents()
assert ws._edit_btn.isHidden() is False
assert ws._edit_btn.isEnabled() is True
def test_dang_sua_thi_nut_sua_mo_lai_va_nut_luu_sang(qapp, ws):
"""Chống sửa quá tay: "Sửa project" chỉ sáng khi CHƯA ở chế độ sửa."""
_mo_mot_project(qapp, ws)
ws.set_project_editable(True)
qapp.processEvents()
assert ws._edit_btn.isEnabled() is False
assert ws._save_btn.isEnabled() is True
assert ws._browse_btn.isEnabled() is True
def test_bo_chon_project_thi_nut_sua_mo_lai(qapp, ws):
"""Xoá project đang mở đưa ``_current_id`` về rỗng — nút phải mờ lại."""
_mo_mot_project(qapp, ws)
ws._current_id = ""
ws.project_selected.emit("")
qapp.processEvents()
assert ws._edit_btn.isEnabled() is False
assert ws._edit_btn.isHidden() is True
-223
View File
@@ -1,223 +0,0 @@
"""Mỗi thư mục làm việc chỉ được thuộc về MỘT project.
Thư mục làm việc vừa là sandbox (agent chỉ được đọc/ghi bên trong nó) vừa là
kho kiến thức chung của project (file ở gốc thư mục được mọi đoạn chat tự đọc).
Hai project trỏ vào cùng một thư mục là đọc lẫn dữ liệu của nhau và ghi đè lên
nhau — đúng điều mà docstring đầu ``core/projects.py`` nói sandbox sinh ra để
ngăn, nhưng trước đây không có gì chặn.
Hai nhóm bài:
* **Luật** — ``folder_conflict`` nhận diện trùng, kể cả khác hoa thường, khác
kiểu dấu phân cách, và LỒNG NHAU (đứng ở thư mục cha thì vẫn với tới được
file của project con).
* **Giao diện** — nhãn cảnh báo dưới ô "Thư mục làm việc" hiện đúng lúc, vì dữ
liệu cũ có thể đã trùng sẵn và luật mới chỉ chặn từ lúc chọn trở đi.
"""
from __future__ import annotations
import os
import pytest
from cowork_local.core import projects as projects_mod
from cowork_local.core.projects import Project, WORKSPACES_DIR, folder_conflict
def _kho(monkeypatch, *ds: Project) -> None:
"""Giả lập kho project, không đụng ``~/.cowork_local`` thật."""
monkeypatch.setattr(projects_mod, "list_projects", lambda directory=None: list(ds))
# ---- luật: nhận diện trùng ----------------------------------------------
def test_trung_y_het_thi_bi_bat(monkeypatch, tmp_path):
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
khac = folder_conflict(str(tmp_path), ignore_id="b")
assert khac is not None and khac.project_id == "a"
def test_khac_hoa_thuong_va_dau_phan_cach_van_la_trung(monkeypatch, tmp_path):
"""Trên Windows ``D:/Work`` và ``d:/work`` là cùng một thư mục."""
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
lech = str(tmp_path).replace(os.sep, "/")
if os.name == "nt":
lech = lech.upper()
assert folder_conflict(lech, ignore_id="b") is not None
def test_thu_muc_con_nam_trong_thu_muc_cua_project_khac_la_trung(monkeypatch, tmp_path):
"""Project kia đứng ở thư mục cha thì vẫn đọc/ghi được thư mục con này."""
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
assert folder_conflict(str(tmp_path / "con"), ignore_id="b") is not None
def test_thu_muc_cha_chua_thu_muc_cua_project_khac_la_trung(monkeypatch, tmp_path):
"""Chiều ngược lại cũng phải bắt: chọn thư mục cha là ôm trọn project kia."""
_kho(monkeypatch, Project(project_id="a", name="A",
output_dir=str(tmp_path / "con")))
assert folder_conflict(str(tmp_path), ignore_id="b") is not None
def test_ten_na_na_nhung_khong_long_nhau_thi_khong_trung(monkeypatch, tmp_path):
"""Bẫy của so sánh tiền tố: ``work2`` KHÔNG nằm trong ``work``."""
_kho(monkeypatch, Project(project_id="a", name="A",
output_dir=str(tmp_path / "work")))
assert folder_conflict(str(tmp_path / "work2"), ignore_id="b") is None
def test_project_chua_dat_thu_muc_rieng_van_dang_chiem_thu_muc_quan_ly(monkeypatch):
"""``output_dir`` rỗng không có nghĩa là "chưa chiếm chỗ nào": project vẫn
đang dùng thư mục quản lý sẵn, và chính nó hay bị chọn nhầm."""
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=""))
assert folder_conflict(str(WORKSPACES_DIR / "a"), ignore_id="b") is not None
def test_giu_nguyen_thu_muc_cua_chinh_no_thi_khong_phai_trung(monkeypatch, tmp_path):
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
assert folder_conflict(str(tmp_path), ignore_id="a") is None
def test_thu_muc_chua_ai_dung_thi_di_qua(monkeypatch, tmp_path):
_kho(monkeypatch, Project(project_id="a", name="A",
output_dir=str(tmp_path / "cua-a")))
assert folder_conflict(str(tmp_path / "cua-b"), ignore_id="b") is None
def test_duong_dan_rong_khong_bi_coi_la_trung(monkeypatch, tmp_path):
"""Ô trống là "chưa chọn", không phải "trùng" — khác hẳn nhau."""
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
assert folder_conflict("", ignore_id="b") is None
assert folder_conflict(" ", ignore_id="b") is None
# ---- i18n: ba key mới phải đủ ba ngôn ngữ -------------------------------
@pytest.mark.parametrize("key", [
"workspace.folder_taken_title", "workspace.folder_taken_body",
"workspace.folder_shared_warning",
])
def test_key_moi_co_du_ba_ngon_ngu(key):
from cowork_local import i18n
entry = i18n.STRINGS[key]
for lang in ("en", "ja", "vi"):
assert entry.get(lang), f"{key} thiếu {lang}"
# ---- giao diện: cảnh báo cho dữ liệu đã trùng sẵn -----------------------
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
@pytest.fixture(scope="module")
def ws(qapp, tmp_path_factory):
"""Một cửa sổ cho cả module — dựng nhiều MainWindow làm Qt chết giữa chừng."""
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
config_path = tmp_path_factory.mktemp("cfg") / "config.json"
build_config(config_path)
window = MainWindow(build_context(config_path))
yield window.workspace
window.close()
def _mo_project(qapp, ws, monkeypatch, dang_mo: Project, *nhung_cai_khac: Project):
"""Mở ``dang_mo`` trên biểu mẫu, với kho chứa cả các project còn lại."""
_kho(monkeypatch, dang_mo, *nhung_cai_khac)
monkeypatch.setattr(projects_mod, "load_project",
lambda pid, directory=None: dang_mo if pid == dang_mo.project_id else None)
ws._current_id = dang_mo.project_id
ws.project_selected.emit(dang_mo.project_id)
qapp.processEvents()
def test_canh_bao_hien_khi_project_dang_dung_chung_thu_muc(qapp, ws, monkeypatch, tmp_path):
"""Đúng trạng thái trong ảnh người dùng gửi: hai project cùng một thư mục."""
_mo_project(qapp, ws, monkeypatch,
Project(project_id="b", name="test3", output_dir=str(tmp_path)),
Project(project_id="a", name="test2", output_dir=str(tmp_path)))
assert ws._folder_warn_lbl.isHidden() is False
assert "test2" in ws._folder_warn_lbl.text()
def test_khong_canh_bao_khi_thu_muc_rieng(qapp, ws, monkeypatch, tmp_path):
_mo_project(qapp, ws, monkeypatch,
Project(project_id="b", name="test3", output_dir=str(tmp_path / "b")),
Project(project_id="a", name="test2", output_dir=str(tmp_path / "a")))
assert ws._folder_warn_lbl.isHidden() is True
def test_nhan_canh_bao_nam_ngay_duoi_o_thu_muc_lam_viec(ws):
"""Cảnh báo phải ở cạnh thứ nó nói tới, không rơi xuống cuối biểu mẫu."""
from cowork_local.presentation.workspace.project_editing import _row_layout_of
from cowork_local.presentation.workspace.project_folder_rules import _layout_chua
hang = _row_layout_of(ws.folder_lbl)
layout, vi_tri = _layout_chua(ws.folder_lbl.parentWidget().layout(), hang)
assert layout.itemAt(vi_tri + 1).widget() is ws._folder_warn_lbl
# ---- hành vi: chọn thư mục đã thuộc project khác thì KHÔNG được ghi ------
def test_chon_thu_muc_trung_thi_khong_ghi_gi(qapp, ws, monkeypatch, tmp_path):
"""Đây là cổng chặn thật, ở đúng nút "Đổi" mà người dùng bấm."""
from PySide6.QtWidgets import QFileDialog, QMessageBox
from cowork_local.ui import workspace_tab as wt
cua_toi = Project(project_id="b", name="test3", output_dir=str(tmp_path / "b"))
cua_nguoi_khac = Project(project_id="a", name="test2", output_dir=str(tmp_path / "a"))
_mo_project(qapp, ws, monkeypatch, cua_toi, cua_nguoi_khac)
da_ghi = []
monkeypatch.setattr(projects_mod, "save_project",
lambda project, directory=None: da_ghi.append(project))
da_bao = []
monkeypatch.setattr(QMessageBox, "warning",
staticmethod(lambda *a, **k: da_bao.append(a)))
# Người dùng chọn đúng thư mục của project kia.
monkeypatch.setattr(QFileDialog, "getExistingDirectory",
staticmethod(lambda *a, **k: str(tmp_path / "a")))
wt.WorkspaceTab._pick_folder(ws)
assert da_ghi == [], "đã ghi đè output_dir dù thư mục thuộc project khác"
assert cua_toi.output_dir == str(tmp_path / "b"), "thư mục cũ bị đổi mất"
assert da_bao, "chặn im lặng — người dùng không biết vì sao không đổi được"
def test_chon_thu_muc_tu_do_thi_van_doi_duoc(qapp, ws, monkeypatch, tmp_path):
"""Chặn một chiều là hỏng tính năng — thư mục chưa ai dùng phải đổi được."""
from PySide6.QtWidgets import QFileDialog
from cowork_local.ui import workspace_tab as wt
cua_toi = Project(project_id="b", name="test3", output_dir=str(tmp_path / "b"))
cua_nguoi_khac = Project(project_id="a", name="test2", output_dir=str(tmp_path / "a"))
_mo_project(qapp, ws, monkeypatch, cua_toi, cua_nguoi_khac)
da_ghi = []
monkeypatch.setattr(projects_mod, "save_project",
lambda project, directory=None: da_ghi.append(project))
monkeypatch.setattr(QFileDialog, "getExistingDirectory",
staticmethod(lambda *a, **k: str(tmp_path / "hoan-toan-moi")))
wt.WorkspaceTab._pick_folder(ws)
assert len(da_ghi) == 1
assert cua_toi.output_dir == str(tmp_path / "hoan-toan-moi")
-3
View File
@@ -123,12 +123,9 @@ class CoworkTab(ChatPanel):
# workspace (its sandbox + shared-knowledge root) — not the
# global default-output setting.
from ..core.projects import save_project
from ..presentation.workspace.project_folder_rules import folder_taken_blocked
project = self._project()
if project is not None:
if folder_taken_blocked(self, chosen, self.project_id):
return
project.output_dir = chosen
save_project(project)
else:
+4 -2
View File
@@ -78,7 +78,8 @@ _PATHS = {
"briefcase": '<rect x="2" y="7" width="20" height="14" rx="2"/>'
'<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>',
"award": '<circle cx="12" cy="8" r="7"/><polyline points="8.2 13.9 7 22 12 19 17 22 15.8 13.9"/>',
"gear": '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0L6.2 6.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
"gear": '<circle cx="12" cy="12" r="3"/>'
'<path d="M12 1v4M12 19v4M4.2 4.2l2.8 2.8M17 17l2.8 2.8M1 12h4M19 12h4M4.2 19.8L7 17M17 7l2.8-2.8"/>',
"globe": '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>'
'<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
"logout": '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>'
@@ -198,7 +199,8 @@ _PATHS = {
'<line x1="7" y1="15" x2="17" y2="15"/>', # = beaker
"sparkle": '<path d="M12 3l1.8 4.8L18.5 9.5 13.8 11.2 12 16l-1.8-4.8L5.5 9.5l4.7-1.7z"/>'
'<path d="M19 15l.7 1.9L21.5 17.5l-1.8.7L19 20l-.7-1.8L16.5 17.5l1.8-.6z"/>', # = sparkles
"settings": '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0L6.2 6.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>', # = gear
"settings": '<circle cx="12" cy="12" r="3"/>'
'<path d="M12 1v4M12 19v4M4.2 4.2l2.8 2.8M17 17l2.8 2.8M1 12h4M19 12h4M4.2 19.8L7 17M17 7l2.8-2.8"/>', # = gear
}
+16 -61
View File
@@ -27,14 +27,13 @@ from PySide6.QtWidgets import (
from ..i18n import on_language_changed, tr
from ..presentation.workspace.project_editing import ProjectEditingMixin, ProjectRow
from ..presentation.workspace.project_folder_rules import ProjectFolderRuleMixin, folder_taken_blocked
from ..state import AppContext
from .icons import collapse_left_icon, icon
from .osutil import open_folder
from .widgets import CollapseStrip
class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
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).
@@ -203,26 +202,27 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
cpl.addWidget(self._cowork)
self._cowork_tab_idx = self.tabs.addTab(cowork_page, tr("workspace.tab_cowork"))
# Co4E and Folder are intentionally placeholders at startup. Their
# widget trees pull in a large amount of Qt/UI code, but neither is on
# the initial Project surface. The real page is created exactly once
# when its tab is first selected (see _ensure_heavy_tab).
self._co4e = None
self._folder = None
self._co4e_placeholder = QWidget()
self._folder_placeholder = QWidget()
self._co4e_tab_idx = self.tabs.addTab(self._co4e_placeholder, tr("workspace.tab_co4e"))
# 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.
self._folder_tab_idx = self.tabs.addTab(self._folder_placeholder, tr("workspace.tab_folder"))
from ..presentation.folder.folder_tab import FolderTab
self._graph_placeholder = QWidget()
self._graphrag_tab_idx = self.tabs.addTab(
self._structure if self._structure is not None else self._graph_placeholder,
tr("workspace.tab_graphrag"))
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)
@@ -307,7 +307,6 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
cloud_row.addWidget(self._cloud_sync_btn)
cloud_row.addStretch(1)
rl.addLayout(cloud_row)
self._cloud_pick_btn.hide()
self._cloud_badge_lbl = QLabel()
self._cloud_badge_lbl.setWordWrap(True)
self._cloud_badge_lbl.hide()
@@ -421,50 +420,10 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
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 in (self._co4e_tab_idx, self._folder_tab_idx, self._graphrag_tab_idx):
self._ensure_heavy_tab(idx)
if idx == self._graphrag_tab_idx and self._structure is not None:
self._structure.auto_scan_and_fit()
self._apply_pane_visibility()
def _ensure_heavy_tab(self, idx: int):
"""Build a heavy Workspace child once, replacing its placeholder."""
if idx == self._co4e_tab_idx and self._co4e is None:
from .co4e_tab import Co4ETab
real = Co4ETab(self.ctx)
self._co4e = real
self.tabs.removeTab(idx)
self.tabs.insertTab(idx, real, tr("workspace.tab_co4e"))
self.tabs.setCurrentIndex(idx)
self.tabs.setTabToolTip(idx, tr("workspace.tab_co4e_tooltip"))
self._bind_project(self._current_id)
return real
if idx == self._folder_tab_idx and self._folder is None:
from ..presentation.folder.folder_tab import FolderTab
real = FolderTab(self.ctx, cowork=self._cowork)
real.status_message.connect(self.status_message)
self._folder = real
self.tabs.removeTab(idx)
self.tabs.insertTab(idx, real, tr("workspace.tab_folder"))
self.tabs.setCurrentIndex(idx)
self._bind_project(self._current_id)
return real
if idx == self._graphrag_tab_idx and self._structure is None:
from ..presentation.graph.structure_graph_view import StructureGraphView
real = StructureGraphView(self.ctx)
real.status_message.connect(self.status_message)
if self._cowork is not None:
self._cowork.output_changed.connect(real.schedule_rescan)
self._structure = real
self.tabs.removeTab(idx)
self.tabs.insertTab(idx, real, tr("workspace.tab_graphrag"))
self.tabs.setCurrentIndex(idx)
self._bind_project(self._current_id)
return real
return {self._co4e_tab_idx: self._co4e,
self._folder_tab_idx: self._folder,
self._graphrag_tab_idx: self._structure}.get(idx)
def _apply_pane_visibility(self) -> None:
"""Which side panes accompany each sub-tab:
@@ -942,8 +901,6 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
self, tr("workspace.browse_tooltip"), str(project.workspace_dir()))
if not chosen:
return
if folder_taken_blocked(self, chosen, pid):
return
project.output_dir = chosen
save_project(project)
self.folder_lbl.setText(chosen)
@@ -987,8 +944,6 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
if not cloud_source:
return
local_dir = WORKSPACES_DIR / project.project_id / "_cloud_mirror"
if folder_taken_blocked(self, str(local_dir), pid):
return
self._cloud_pick_btn.setEnabled(False)
try:
token = self._cloud_token()