Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7a41b3658 | ||
|
|
bbdf146d2c | ||
|
|
35f24e0e28 | ||
|
|
2759ed94ba | ||
|
|
8548c1e923 | ||
|
|
caf3b74931 | ||
|
|
c00b83cd1a | ||
|
|
a04f8a928d | ||
|
|
cc8d5c8c0a | ||
|
|
2e3e719259 | ||
|
|
c699beb6fd | ||
|
|
7607f44030 | ||
|
|
cbae2604db | ||
|
|
b71a622227 |
Binary file not shown.
@@ -100,11 +100,15 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
||||
"resource_limit_cpu_percent": 80, # 0 = unlimited; caps a run_command/install_package process TREE's total CPU%
|
||||
"resource_limit_memory_mb": 2048, # 0 = unlimited; caps total RSS memory (MB)
|
||||
"resource_limit_disk_mb": 512, # 0 = unlimited; caps total disk read+write (MB)
|
||||
"block_network": True, # strip proxy env / point at a black-hole address for agent-run commands
|
||||
# Cut the agent off the network: proxy env pointed at a black hole for
|
||||
# agent-run shell commands, PLUS a flat refusal from every tool tagged
|
||||
# ToolCapability.NETWORK (fetch_url, jira_*, install_package) — those
|
||||
# reach the net in-process, where the proxy trick has nothing to act on.
|
||||
"block_network": True,
|
||||
# Allow the agent's fetch_url tool to read web pages / online documents /
|
||||
# SharePoint-OneDrive share links. SEPARATE from block_network (that only
|
||||
# sandboxes agent-run shell commands) — reading a URL for info is safe and
|
||||
# useful, so this defaults ON. Toggle in Settings → Security.
|
||||
# SharePoint-OneDrive share links. Its own toggle — reading a URL for info
|
||||
# is safe and useful, so this defaults ON — but block_network outranks it:
|
||||
# with the network blocked the tool is refused either way.
|
||||
"allow_url_fetch": True,
|
||||
"sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD
|
||||
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
|
||||
|
||||
@@ -527,6 +527,15 @@ 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.
|
||||
|
||||
+6
-1
@@ -327,7 +327,12 @@ def run_code(
|
||||
else:
|
||||
emit({"type": "tool_start", "id": tc_id, "name": name})
|
||||
if is_extra and extra_executor is not None:
|
||||
result = extra_executor(name, args)
|
||||
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)
|
||||
else:
|
||||
def on_output(line: str, _id=tc_id, _name=name) -> None:
|
||||
emit({"type": "tool_output", "id": _id, "name": _name, "delta": line})
|
||||
|
||||
+19
-8
@@ -94,17 +94,28 @@ 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:
|
||||
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
|
||||
)
|
||||
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)
|
||||
except OSError:
|
||||
return [], 0
|
||||
files = matched if max_files <= 0 else matched[:max_files]
|
||||
return files, len(matched)
|
||||
files.sort(key=lambda p: str(p).lower())
|
||||
return files, total
|
||||
|
||||
|
||||
def find_soffice() -> str | None:
|
||||
|
||||
+30
-1
@@ -16,6 +16,17 @@ 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."""
|
||||
@@ -78,6 +89,7 @@ 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
|
||||
|
||||
|
||||
@@ -85,6 +97,7 @@ 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
|
||||
|
||||
@@ -96,6 +109,7 @@ 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:
|
||||
@@ -105,6 +119,7 @@ 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]:
|
||||
@@ -197,8 +212,16 @@ 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]] = []
|
||||
for path in directory.glob("*.json"):
|
||||
with span("history.list", query=bool(q)):
|
||||
for path in directory.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
@@ -221,4 +244,10 @@ 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
|
||||
|
||||
@@ -24,6 +24,7 @@ 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
|
||||
@@ -82,6 +83,53 @@ 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."""
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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.
|
||||
@@ -234,6 +234,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"},
|
||||
"monitoring.page_size_label": {
|
||||
"en": "Rows/page:", "ja": "1ページの行数:", "vi": "Số dòng/trang:"},
|
||||
"monitoring.page_indicator": {
|
||||
"en": "Page {page}/{total}", "ja": "{page}/{total} ページ", "vi": "Trang {page}/{total}"},
|
||||
"monitoring.page_prev": {
|
||||
"en": "Previous page", "ja": "前のページ", "vi": "Trang trước"},
|
||||
"monitoring.page_next": {
|
||||
"en": "Next page", "ja": "次のページ", "vi": "Trang sau"},
|
||||
"monitoring.pricing_title": {
|
||||
"en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)",
|
||||
"vi": "Bảng giá model (USD / 1 triệu token)"},
|
||||
|
||||
@@ -344,12 +344,6 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"en": "Let the control agent review a command with AI before it runs.",
|
||||
"ja": "実行前に制御エージェントがAIでコマンドを確認します。",
|
||||
"vi": "Cho control-agent dùng AI xét lệnh trước khi chạy."},
|
||||
"settings.sandbox_pw_unset_title": {
|
||||
"en": "Sandbox Security", "ja": "サンドボックスセキュリティ", "vi": "Bảo mật Sandbox"},
|
||||
"settings.sandbox_pw_unset_body": {
|
||||
"en": "No sandbox password is set yet, so these settings stay locked. Set COWORK_SANDBOX_PASSWORD, or ask your administrator.",
|
||||
"ja": "サンドボックスのパスワードが未設定のため、この設定はロックされたままです。COWORK_SANDBOX_PASSWORD を設定するか、管理者にお問い合わせください。",
|
||||
"vi": "Chưa đặt mật khẩu sandbox nên nhóm thiết lập này vẫn khóa. Hãy đặt COWORK_SANDBOX_PASSWORD, hoặc liên hệ quản trị viên."},
|
||||
"settings.sandbox_confirm_commands": {
|
||||
"en": "Confirm before Cowork runs a command",
|
||||
"ja": "Cowork がコマンドを実行する前に確認する",
|
||||
|
||||
@@ -177,6 +177,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"vi": "Project cho đoạn chat mới"},
|
||||
"app.nav.no_project": {
|
||||
"en": "No project yet", "ja": "プロジェクトなし", "vi": "Chưa có project"},
|
||||
# KHAC no_project: đã có project, chỉ là người dùng chưa chọn cái nào.
|
||||
"app.nav.pick_project": {
|
||||
"en": "Select a project…", "ja": "プロジェクトを選択…", "vi": "Chọn project…"},
|
||||
"app.nav.recents": {"en": "RECENTS", "ja": "最近", "vi": "GẦN ĐÂY"},
|
||||
"app.nav.all_projects": {
|
||||
"en": "All projects…", "ja": "すべてのプロジェクト…", "vi": "Tất cả project…"},
|
||||
|
||||
@@ -45,28 +45,6 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"ファイアウォールではありません。上のコマンドホワイトリストと併用してください。",
|
||||
"vi": "Kiểm soát ở tầng chính sách (trỏ biến môi trường proxy vào hố đen) — không phải "
|
||||
"firewall tầng kernel. Kết hợp với whitelist lệnh ở trên để phòng thủ nhiều lớp."},
|
||||
"settings.sandbox_pw_label": {
|
||||
"en": "Sandbox Security Password", "ja": "サンドボックスセキュリティのパスワード",
|
||||
"vi": "Mật khẩu Bảo mật Sandbox"},
|
||||
"settings.sandbox_pw_placeholder": {
|
||||
"en": "Enter password to edit sandbox settings",
|
||||
"ja": "サンドボックス設定を変更するにはパスワードを入力してください",
|
||||
"vi": "Nhập mật khẩu để sửa thiết lập sandbox"},
|
||||
"settings.sandbox_unlock_btn": {"en": "Unlock", "ja": "ロック解除", "vi": "Mở khoá"},
|
||||
"settings.sandbox_locked": {
|
||||
"en": "Locked (changes disabled)", "ja": "ロック中(変更できません)",
|
||||
"vi": "Đang khoá (không sửa được)"},
|
||||
"settings.sandbox_unlocked": {
|
||||
"en": "Unlocked", "ja": "ロック解除済み", "vi": "Đã mở khoá"},
|
||||
"settings.sandbox_unlocked_body": {
|
||||
"en": "Sandbox settings unlocked.", "ja": "サンドボックス設定のロックを解除しました。",
|
||||
"vi": "Đã mở khoá thiết lập sandbox."},
|
||||
"settings.sandbox_pw_wrong_title": {
|
||||
"en": "Wrong Password", "ja": "パスワードが違います", "vi": "Sai mật khẩu"},
|
||||
"settings.sandbox_pw_wrong_body": {
|
||||
"en": "Password incorrect. Sandbox settings remain locked.",
|
||||
"ja": "パスワードが正しくありません。サンドボックス設定はロックされたままです。",
|
||||
"vi": "Mật khẩu không đúng. Thiết lập sandbox vẫn bị khoá."},
|
||||
"settings.sandbox_unlimited": {"en": "Unlimited", "ja": "無制限", "vi": "Không giới hạn"},
|
||||
"settings.sandbox_cpu_label": {"en": "CPU limit", "ja": "CPU 制限", "vi": "Giới hạn CPU"},
|
||||
"settings.sandbox_memory_label": {"en": "Memory limit", "ja": "メモリ制限", "vi": "Giới hạn bộ nhớ"},
|
||||
|
||||
@@ -57,6 +57,17 @@ 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 レポートツールを開発中。命名規則に従うこと …」",
|
||||
|
||||
@@ -128,6 +128,15 @@ def install_package(ctx: ToolContext, args: Dict[str, Any],
|
||||
package = str(args.get("package", "")).strip()
|
||||
if not package:
|
||||
return {"ok": False, "output": "No package specified."}
|
||||
# ``pip install`` bắt buộc phải ra internet, mà ``deps.pip_install`` chạy
|
||||
# subprocess với ``os.environ`` nguyên vẹn — biến proxy hố đen của
|
||||
# ``network_blocked_env`` không chạm tới nó. Từ chối thẳng ở đây (giống cách
|
||||
# run_command chặn theo tên các công cụ không đi qua proxy) thay vì để pip
|
||||
# thử 600 giây rồi báo một lỗi proxy khó hiểu.
|
||||
if ctx.block_network:
|
||||
return {"ok": False, "output": (
|
||||
"install_package: network access is blocked by the Sandbox Security Layer "
|
||||
"(\"Block network for agent-run commands\" is on in Settings).")}
|
||||
python = _sandbox_python(ctx, cancel, on_output)
|
||||
ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python)
|
||||
head = f"Installed {package}." if ok else f"Could not install {package}."
|
||||
|
||||
@@ -6,11 +6,26 @@ tag added in R05-T01/domain/tools/tool_registry.py describes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .tool_context import ToolContext
|
||||
|
||||
|
||||
def _network_refusal(ctx: ToolContext, tool: str) -> Optional[Dict[str, Any]]:
|
||||
"""Lời từ chối khi Sandbox Security Layer đang chặn mạng; None nếu được đi.
|
||||
|
||||
``block_network`` trước đây chỉ được đọc ở ``command_tools.py`` (lệnh shell),
|
||||
nên ba tool mang ``ToolCapability.NETWORK`` ở file này vẫn ra internet bình
|
||||
thường trong khi Monitoring báo "Mạng: Bị chặn". Kiểm ở đây, TRƯỚC mọi lời
|
||||
gọi mạng, để công tắc chặn đúng thứ nó nói là chặn.
|
||||
"""
|
||||
if not ctx.block_network:
|
||||
return None
|
||||
return {"ok": False, "output": (
|
||||
f"{tool}: network access is blocked by the Sandbox Security Layer "
|
||||
"(\"Block network for agent-run commands\" is on in Settings).")}
|
||||
|
||||
|
||||
def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Fetch a URL's text content (web page / online document / SharePoint-
|
||||
OneDrive share link) via link_fetch — the same parser task-link attachments
|
||||
@@ -20,6 +35,9 @@ def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"ok": False, "output": "fetch_url: 'url' is required."}
|
||||
if not url.lower().startswith(("http://", "https://")):
|
||||
return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"}
|
||||
blocked = _network_refusal(ctx, "fetch_url")
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
if not ctx.allow_url_fetch:
|
||||
return {"ok": False,
|
||||
"output": ("fetch_url: URL fetching is turned off in Settings → Security "
|
||||
@@ -37,6 +55,9 @@ def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Tìm issue trên Jira bằng JQL."""
|
||||
blocked = _network_refusal(ctx, "jira_search")
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
from cowork_local.core import jira_tool
|
||||
|
||||
out = jira_tool.search(ctx.jira, str(args.get("jql", "")),
|
||||
@@ -47,6 +68,9 @@ def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
def jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Lấy chi tiết một issue Jira theo mã."""
|
||||
blocked = _network_refusal(ctx, "jira_get_issue")
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
from cowork_local.core import jira_tool
|
||||
|
||||
out = jira_tool.get_issue(ctx.jira, str(args.get("key", "")))
|
||||
|
||||
@@ -37,12 +37,16 @@ class ToolContext:
|
||||
# None (default) = no limits, matching pre-existing behavior.
|
||||
resource_limits: Optional[Dict[str, float]] = None
|
||||
# Sandbox Security Layer — Settings' "Block network for agent commands"
|
||||
# (policy-level, see deps.py::network_blocked_env). False (default) =
|
||||
# — the proxy-env block for shell commands (deps.py::network_blocked_env)
|
||||
# AND a flat refusal from every NETWORK-capability tool, which reaches the
|
||||
# net in-process where proxy env vars mean nothing. False (default) =
|
||||
# unrestricted, matching pre-existing behavior.
|
||||
block_network: bool = False
|
||||
# Whether the fetch_url tool may read URLs — SEPARATE from block_network
|
||||
# (reading a web page/share link for info is safe; running networked shell
|
||||
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch.
|
||||
# Whether the fetch_url tool may read URLs. Its own toggle, but NOT a way
|
||||
# around block_network: with the network blocked every NETWORK-capability
|
||||
# tool is refused first (fetch_tools.py::_network_refusal), so this flag only
|
||||
# decides anything while the network is open. Defaults True; set from
|
||||
# agent_security.allow_url_fetch.
|
||||
allow_url_fetch: bool = True
|
||||
# Jira read connector config (base_url/email/api_token) — None disables the
|
||||
# jira_* tools' ability to connect. Populated from config.data["jira"].
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""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 "")
|
||||
@@ -115,10 +115,23 @@ class ChatAgentsMixin:
|
||||
if err:
|
||||
self.status_message.emit(tr("chatpanel.agent_list_error", err=err))
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
self._agent_worker = w
|
||||
w.start()
|
||||
# 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)
|
||||
|
||||
def _populate_agents(self, models, keep: str) -> None:
|
||||
"""Đổ danh sách vào bộ chọn Agent.
|
||||
|
||||
@@ -51,6 +51,8 @@ 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
|
||||
@@ -164,12 +166,20 @@ 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
|
||||
self.set_markdown(self._text)
|
||||
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)
|
||||
|
||||
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()
|
||||
@@ -393,4 +403,3 @@ class ChatView(QScrollArea):
|
||||
ChatHistoryWidget = ChatView
|
||||
|
||||
__all__ = ["ChatView", "ChatHistoryWidget", "MessageBubble"]
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@ from typing import List, Optional
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import QMenu
|
||||
from ...core import co4e
|
||||
from ...core.co4e import STEP_DONE, STEP_RUNNING
|
||||
from ...core.co4e import STEP_RUNNING
|
||||
from ...i18n import tr
|
||||
from ...ui.dialog_buttons import ask_text
|
||||
from ...ui.icons import icon
|
||||
from ...presentation.co4e.co4e_chat_view import _skill_names
|
||||
|
||||
_LOCKED_NODE_STATUSES = (STEP_RUNNING, STEP_DONE)
|
||||
_LOCKED_NODE_STATUSES = (STEP_RUNNING,)
|
||||
|
||||
|
||||
class Co4EWorkflowCrudMixin:
|
||||
@@ -169,8 +169,8 @@ class Co4EWorkflowCrudMixin:
|
||||
def _on_node_selected(self, node_id: str) -> None:
|
||||
"""Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập.
|
||||
|
||||
Bước đang chạy hoặc đã chạy xong thì khoá ô nhập liệu ngay khi nạp —
|
||||
tránh sửa nhầm cấu hình của lần chạy đang xem kết quả.
|
||||
Chỉ khoá ô nhập liệu khi bước ĐANG chạy (DF-002) — chạy xong rồi thì
|
||||
vẫn sửa lại được bình thường.
|
||||
"""
|
||||
for n in self.canvas.nodes():
|
||||
if n.id == node_id:
|
||||
|
||||
@@ -294,11 +294,11 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
def set_locked(self, locked: bool) -> None:
|
||||
"""Khoá/mở khoá các trường chỉnh sửa theo trạng thái chạy của bước.
|
||||
|
||||
Bước đang chạy hoặc đã chạy xong thì khoá lại — tránh sửa nhầm cấu
|
||||
hình trong lúc đang xem kết quả của chính lần chạy đó (sửa xong
|
||||
không rõ là áp dụng cho lần chạy đã xong hay lần chạy tiếp theo).
|
||||
Nút Chạy/Chạy từ đây/Xoá bước vẫn hoạt động bình thường khi khoá —
|
||||
chỉ ô nhập liệu bị khoá, không phải cả panel.
|
||||
Chỉ khoá khi bước ĐANG chạy — tránh sửa nhầm cấu hình trong lúc chưa
|
||||
biết kết quả (DF-002: trước đây còn khoá cả bước đã chạy xong, khiến
|
||||
không sửa lại được sau khi run xong). Nút Chạy/Chạy từ đây/Xoá bước
|
||||
vẫn hoạt động bình thường khi khoá — chỉ ô nhập liệu bị khoá, không
|
||||
phải cả panel.
|
||||
"""
|
||||
self._locked = locked
|
||||
editable = not locked
|
||||
|
||||
@@ -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 not HAS_WEB_ENGINE or self.web is not None:
|
||||
if self.web is not None:
|
||||
return
|
||||
self._ensure_web()
|
||||
if self._graph is None and self.path_edit.text().strip():
|
||||
|
||||
@@ -19,6 +19,7 @@ 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
|
||||
@@ -39,8 +40,9 @@ _UNBOUNDED_PAGE_SIZE = 100_000
|
||||
# day-sharded JSONL — unbounded start/end means EVERY day file ever written
|
||||
# gets re-read and re-parsed on EVERY tick, which is what actually made
|
||||
# Monitoring "gây nặng khi log lớn" (see DF-006): the slowness was never in
|
||||
# rendering (EventTable already caps display at 300 rows — see
|
||||
# shared/event_table.py::_MAX_ROWS), it was this repeated full-history read.
|
||||
# rendering (EventTable paginates client-side, 5-100 rows/page — see
|
||||
# shared/event_table.py::_DEFAULT_PAGE_SIZE), it was this repeated full-history
|
||||
# read.
|
||||
# 30 days is a live-monitoring window, not a hard retention limit — nothing
|
||||
# is deleted, older days are simply not re-read on every 3s tick.
|
||||
_LOG_WINDOW_DAYS = 30
|
||||
@@ -281,12 +283,13 @@ class MonitoringTab(QWidget):
|
||||
"""
|
||||
start = date.today() - timedelta(days=_LOG_WINDOW_DAYS)
|
||||
shared_dir = self.ctx.config.shared_dir
|
||||
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)
|
||||
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)
|
||||
|
||||
def _apply_events_to_event_tabs(self, events: List[dict]) -> None:
|
||||
"""Filters the ALREADY-LOADED event list (see ``_load_events`` — one
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt
|
||||
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor
|
||||
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
|
||||
|
||||
@@ -17,8 +17,8 @@ from ....ui.icons import DOT_GREEN, DOT_RED, icon
|
||||
from .badges import action_label
|
||||
from .formatters import agent_avatar_icon, fmt_event_time
|
||||
|
||||
_MAX_ROWS = 300
|
||||
PAGE_SIZE_OPTIONS = (50, 100, 300, 500, 1000)
|
||||
_DEFAULT_PAGE_SIZE = 20
|
||||
PAGE_SIZE_OPTIONS = (5, 10, 20, 50, 100)
|
||||
|
||||
|
||||
class _TimeItem(QTableWidgetItem):
|
||||
@@ -63,6 +63,12 @@ class EventTable(QTableWidget):
|
||||
"secret_in_output": "warning",
|
||||
}
|
||||
|
||||
# Emitted whenever the rendered page changes (new data, page-size change,
|
||||
# or prev/next navigation) — args are (current_page, page_count), both
|
||||
# 1-based-friendly in that current_page is 0-indexed but page_count is a
|
||||
# plain count. filter_scaffold.py's pager label/buttons listen to this.
|
||||
page_changed = Signal(int, int)
|
||||
|
||||
def __init__(self, show_result: bool = True):
|
||||
# Security Events drops the result column entirely (see _ACTION_TINTS).
|
||||
"""Bảng sự kiện dùng chung của các tab Giám sát.
|
||||
@@ -71,8 +77,10 @@ class EventTable(QTableWidget):
|
||||
là thất bại nên cột ấy chỉ tốn chỗ.
|
||||
"""
|
||||
self._show_result = show_result
|
||||
self._page_size = _MAX_ROWS
|
||||
self._page_size = _DEFAULT_PAGE_SIZE
|
||||
self._current_page = 0
|
||||
self._last_events: List[dict] = []
|
||||
self._sorted_events: List[dict] = []
|
||||
super().__init__(0, 7 if show_result else 6)
|
||||
self.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
@@ -105,21 +113,56 @@ class EventTable(QTableWidget):
|
||||
"""Số dòng đang hiển thị mỗi trang."""
|
||||
return self._page_size
|
||||
|
||||
def page_count(self) -> int:
|
||||
"""Tổng số trang với dữ liệu và số dòng/trang hiện tại (tối thiểu 1)."""
|
||||
if not self._sorted_events:
|
||||
return 1
|
||||
return -(-len(self._sorted_events) // self._page_size) # ceil div
|
||||
|
||||
def current_page(self) -> int:
|
||||
"""Trang đang hiển thị, đánh số từ 0."""
|
||||
return self._current_page
|
||||
|
||||
def go_to_page(self, page: int) -> None:
|
||||
"""Nhảy tới một trang cụ thể (đánh số từ 0), tự kẹp trong khoảng hợp lệ."""
|
||||
self._current_page = page
|
||||
self._render_current_page()
|
||||
|
||||
def next_page(self) -> None:
|
||||
"""Sang trang kế — không làm gì nếu đã ở trang cuối."""
|
||||
self.go_to_page(self._current_page + 1)
|
||||
|
||||
def prev_page(self) -> None:
|
||||
"""Về trang trước — không làm gì nếu đã ở trang đầu."""
|
||||
self.go_to_page(self._current_page - 1)
|
||||
|
||||
def set_page_size(self, n: int) -> None:
|
||||
"""Đổi số dòng hiển thị mỗi trang rồi vẽ lại với dữ liệu đã có sẵn
|
||||
(không cần refresh lại từ nguồn — set_events() đã lưu lại lần đổ gần nhất)."""
|
||||
"""Đổi số dòng hiển thị mỗi trang, quay về trang đầu, rồi vẽ lại với dữ
|
||||
liệu đã có sẵn (không cần refresh lại từ nguồn)."""
|
||||
self._page_size = n
|
||||
self.set_events(self._last_events)
|
||||
self._current_page = 0
|
||||
self._render_current_page()
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, cắt ở ``self._page_size``
|
||||
(đổi được qua ``set_page_size`` — control "Số dòng/trang" ở filter_scaffold.py).
|
||||
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, chia trang theo
|
||||
``self._page_size`` — xem qua trang khác bằng ``next_page``/``prev_page``
|
||||
(nút tiến/lùi ở filter_scaffold.py), không còn bị cắt bỏ vĩnh viễn như
|
||||
trước (DF-006)."""
|
||||
self._last_events = events
|
||||
self._sorted_events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)
|
||||
self._current_page = 0
|
||||
self._render_current_page()
|
||||
|
||||
def _render_current_page(self) -> None:
|
||||
"""Vẽ đúng một trang (theo ``self._current_page``/``self._page_size``)
|
||||
từ ``self._sorted_events`` đã sắp sẵn.
|
||||
|
||||
Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc
|
||||
nạp chậm đi theo bậc hai.
|
||||
"""
|
||||
self._last_events = events
|
||||
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:self._page_size]
|
||||
self._current_page = max(0, min(self._current_page, self.page_count() - 1))
|
||||
start = self._current_page * self._page_size
|
||||
events = self._sorted_events[start:start + self._page_size]
|
||||
self.setSortingEnabled(False)
|
||||
self.setRowCount(len(events))
|
||||
for row, ev in enumerate(events):
|
||||
@@ -164,6 +207,7 @@ class EventTable(QTableWidget):
|
||||
self.setItem(row, col, item)
|
||||
self.setSortingEnabled(True)
|
||||
self.apply_filter(getattr(self, "_filter_needle", ""))
|
||||
self.page_changed.emit(self._current_page, self.page_count())
|
||||
|
||||
def apply_filter(self, needle: str) -> None:
|
||||
"""Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường)."""
|
||||
|
||||
@@ -95,20 +95,52 @@ def build_filter_scaffold(
|
||||
if with_page_size and isinstance(table, EventTable):
|
||||
# DF-006: the item-per-page count was never surfaced anywhere in
|
||||
# the UI (design called for it) — EventTable already trims to a
|
||||
# page size internally (default 300), this just makes that
|
||||
# number visible AND user-choosable instead of a fixed constant.
|
||||
# page size internally, this just makes that number visible AND
|
||||
# user-choosable instead of a fixed constant.
|
||||
page_size_lbl = QLabel(tr("monitoring.page_size_label"))
|
||||
page_size_combo = QComboBox()
|
||||
for n in PAGE_SIZE_OPTIONS:
|
||||
page_size_combo.addItem(str(n), n)
|
||||
current = table.page_size()
|
||||
page_size_combo.setCurrentIndex(
|
||||
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 2)
|
||||
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 0)
|
||||
page_size_combo.currentIndexChanged.connect(
|
||||
lambda i: table.set_page_size(page_size_combo.itemData(i)))
|
||||
row.addWidget(page_size_lbl)
|
||||
row.addWidget(page_size_combo)
|
||||
parts.update(page_size_label=page_size_lbl, page_size_combo=page_size_combo)
|
||||
|
||||
# DF-006 follow-up: trimming to a page size alone silently dropped
|
||||
# every row past it with no way back to see them — prev/next
|
||||
# buttons plus a "trang X/Y" indicator make the rest reachable.
|
||||
page_prev_btn = QPushButton()
|
||||
page_prev_btn.setIcon(icon("chevron-left"))
|
||||
page_prev_btn.setCursor(Qt.PointingHandCursor)
|
||||
bind_tip(page_prev_btn, "monitoring.page_prev")
|
||||
page_next_btn = QPushButton()
|
||||
page_next_btn.setIcon(icon("chevron-right"))
|
||||
page_next_btn.setCursor(Qt.PointingHandCursor)
|
||||
bind_tip(page_next_btn, "monitoring.page_next")
|
||||
page_indicator_lbl = QLabel()
|
||||
|
||||
def _refresh_pager(cur: int = None, total: int = None) -> None:
|
||||
if cur is None or total is None:
|
||||
cur, total = table.current_page(), table.page_count()
|
||||
page_indicator_lbl.setText(tr("monitoring.page_indicator", page=cur + 1, total=total))
|
||||
page_prev_btn.setEnabled(cur > 0)
|
||||
page_next_btn.setEnabled(cur < total - 1)
|
||||
|
||||
page_prev_btn.clicked.connect(table.prev_page)
|
||||
page_next_btn.clicked.connect(table.next_page)
|
||||
table.page_changed.connect(_refresh_pager)
|
||||
_refresh_pager()
|
||||
|
||||
row.addWidget(page_prev_btn)
|
||||
row.addWidget(page_indicator_lbl)
|
||||
row.addWidget(page_next_btn)
|
||||
parts.update(
|
||||
page_size_label=page_size_lbl, page_size_combo=page_size_combo,
|
||||
page_prev_btn=page_prev_btn, page_next_btn=page_next_btn,
|
||||
page_indicator_label=page_indicator_lbl, page_pager_refresh=_refresh_pager)
|
||||
lay.addLayout(row)
|
||||
parts.update(filter_edit=search, ai_filter_btn=ai_btn)
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ class ActionLogsTab(QWidget):
|
||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||
self.detail_panel = parts["detail_panel"]
|
||||
self.page_size_label = parts["page_size_label"]
|
||||
self.page_pager_refresh = parts["page_pager_refresh"]
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
"""Đổ danh sách sự kiện vào bảng."""
|
||||
@@ -47,6 +48,7 @@ class ActionLogsTab(QWidget):
|
||||
self.title_lbl.setText(tr(self.title_key))
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
||||
self.page_pager_refresh()
|
||||
|
||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||
|
||||
@@ -34,6 +34,7 @@ class McpTab(QWidget):
|
||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||
self.detail_panel = parts["detail_panel"]
|
||||
self.page_size_label = parts["page_size_label"]
|
||||
self.page_pager_refresh = parts["page_pager_refresh"]
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
"""Đổ danh sách sự kiện vào bảng."""
|
||||
@@ -47,6 +48,7 @@ class McpTab(QWidget):
|
||||
self.title_lbl.setText(tr(self.title_key))
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
||||
self.page_pager_refresh()
|
||||
|
||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||
|
||||
@@ -40,6 +40,7 @@ class SecurityEventsTab(QWidget):
|
||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||
self.detail_panel = parts["detail_panel"]
|
||||
self.page_size_label = parts["page_size_label"]
|
||||
self.page_pager_refresh = parts["page_pager_refresh"]
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
"""Đổ danh sách sự kiện vào bảng."""
|
||||
@@ -53,6 +54,7 @@ class SecurityEventsTab(QWidget):
|
||||
self.title_lbl.setText(tr(self.title_key))
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
||||
self.page_pager_refresh()
|
||||
|
||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||
|
||||
@@ -216,7 +216,8 @@ 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 not bool(self.ctx.config.agent_security.get("allow_url_fetch", True))
|
||||
or bool(self.ctx.config.agent_security.get("block_network", False)))
|
||||
if disabled:
|
||||
self.test_internet_status.setText(tr("tools_admin.internet_disabled"))
|
||||
self.test_internet_status.setStyleSheet("color: #c00;")
|
||||
|
||||
@@ -27,7 +27,14 @@ 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()
|
||||
|
||||
@@ -32,9 +32,7 @@ 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
|
||||
|
||||
|
||||
@@ -111,10 +109,9 @@ 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 = StructureGraphView(ctx)
|
||||
self.structure.status_message.connect(self.statusBar().showMessage)
|
||||
self.cowork.output_changed.connect(self.structure.schedule_rescan)
|
||||
self.structure = None
|
||||
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.
|
||||
|
||||
@@ -197,9 +197,9 @@ class NavRailMixin:
|
||||
def _nav_rows(self):
|
||||
"""(tree, page, sub, label, icon, enabled) for every row, rail order.
|
||||
|
||||
Workspace contributes all five of its sub-views — including the two the
|
||||
project gate currently disables — so the rail never changes shape while
|
||||
the user is looking at it.
|
||||
Workspace contributes all five of its sub-views; ``_rebuild_nav`` bỏ
|
||||
những hàng mà cổng project đang đóng (Cowork, GraphRAG) thay vì hiện
|
||||
chúng ở dạng mờ.
|
||||
"""
|
||||
rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on)
|
||||
for label, sub, ic, on in self.workspace.nav_entries()]
|
||||
@@ -238,15 +238,13 @@ class NavRailMixin:
|
||||
tree.clear()
|
||||
tree.blockSignals(blocked)
|
||||
for tree, page, sub, label, icon_name, enabled in spec:
|
||||
if not enabled:
|
||||
# Cổng project đóng → bỏ hẳn hàng, không hiện dạng mờ nữa.
|
||||
continue
|
||||
it = QTreeWidgetItem([""] if self._nav_collapsed else [label])
|
||||
it.setIcon(0, _icon(icon_name))
|
||||
it.setData(0, Qt.UserRole, {"page": page, "sub": sub})
|
||||
if not enabled:
|
||||
# Same gate as before, shown instead of hidden: the row stays
|
||||
# in place, greyed, and says why it cannot be opened.
|
||||
it.setDisabled(True)
|
||||
it.setToolTip(0, tr("app.nav.needs_project"))
|
||||
elif self._nav_collapsed:
|
||||
if self._nav_collapsed:
|
||||
it.setToolTip(0, label)
|
||||
blocked = tree.blockSignals(True)
|
||||
tree.addTopLevelItem(it)
|
||||
|
||||
@@ -142,10 +142,11 @@ 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:
|
||||
self.workspace.refresh() # re-list projects + threads on entry
|
||||
if page == self._ROW_WORKSPACE and was_page != page:
|
||||
self.workspace.refresh() # refresh only when entering Workspace
|
||||
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
|
||||
|
||||
@@ -19,9 +19,8 @@ _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.
|
||||
# 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
|
||||
# Settings dùng cùng nhịp hàng với Dashboard và Monitoring.
|
||||
_NAV_SETTINGS_GAP = 0
|
||||
# 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
|
||||
|
||||
@@ -42,6 +42,12 @@ class RailProjectMixin:
|
||||
# No project yet: say so, and say what to do about it, instead of
|
||||
# leaving an empty box and a button that silently does nothing.
|
||||
self.nav_project.addItem(tr("app.nav.no_project"), "")
|
||||
elif not current:
|
||||
# Có project nhưng CHƯA chọn cái nào (mở app lên, hoặc vừa xoá
|
||||
# project đang mở). Không có mục này thì combo rơi về mục 0 và
|
||||
# chỉ bừa vào project đầu danh sách, trong khi cổng
|
||||
# Cowork/GraphRAG vẫn đóng — hai chỗ nói hai đằng.
|
||||
self.nav_project.insertItem(0, tr("app.nav.pick_project"), "")
|
||||
idx = self.nav_project.findData(current)
|
||||
if idx >= 0:
|
||||
self.nav_project.setCurrentIndex(idx)
|
||||
|
||||
@@ -42,7 +42,14 @@ class SessionEventsMixin:
|
||||
self.sidebar.refresh()
|
||||
self._refresh_rail_recents() # the rail shortcut follows the panel
|
||||
|
||||
QTimer.singleShot(0, _do)
|
||||
# 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)
|
||||
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 —
|
||||
@@ -106,4 +113,5 @@ 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
|
||||
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too
|
||||
if getattr(self, "structure", None) is not None:
|
||||
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too
|
||||
|
||||
@@ -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
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStyle
|
||||
from ...i18n import tr
|
||||
from ...ui.icons import icon as _icon
|
||||
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET, _NAV_SETTINGS_GAP
|
||||
from .rail_metrics import _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,11 +59,17 @@ 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, 0, 8, 0)
|
||||
srow.setSpacing(_NAV_ROW_GAP)
|
||||
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)
|
||||
self._nav_settings_icon = QLabel()
|
||||
self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16))
|
||||
self._nav_settings_icon.setFixedSize(16, 16)
|
||||
self._nav_settings_icon.setPixmap(_icon("gear").pixmap(16, 16))
|
||||
self._nav_settings_icon.setFixedSize(22, 16)
|
||||
self._nav_settings_text = QLabel(tr("app.settings"))
|
||||
srow.addWidget(self._nav_settings_icon)
|
||||
srow.addWidget(self._nav_settings_text)
|
||||
|
||||
@@ -52,7 +52,7 @@ class ProjectRow(QWidget):
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(6, 4, 6, 4)
|
||||
lay.setSpacing(0)
|
||||
lay.setSpacing(3)
|
||||
self.title_label = QLabel(name)
|
||||
self.counts_label = QLabel()
|
||||
self.counts_label.setObjectName("hint")
|
||||
@@ -90,6 +90,7 @@ 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``.
|
||||
|
||||
@@ -133,10 +134,17 @@ 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 -------------------------------------------
|
||||
@@ -153,15 +161,14 @@ 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)
|
||||
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)
|
||||
# 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
|
||||
@@ -171,15 +178,25 @@ class ProjectEditingMixin:
|
||||
self._repolish(self._edit_btn)
|
||||
|
||||
def _sync_project_buttons(self, *_a) -> None:
|
||||
"""Ẩn "Sửa project" và "Lưu project" ngoài sub-tab Project.
|
||||
"""Đồ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.
|
||||
|
||||
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.
|
||||
Ẩ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:
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""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()
|
||||
@@ -19,6 +19,14 @@ set "APPHOME=%LOCALAPPDATA%\CoworkLocal"
|
||||
set "VENV=%APPHOME%\venv"
|
||||
set "LAUNCHER=%APPHOME%\launcher"
|
||||
|
||||
rem An cua so console NGAY TU DAU, ke ca trong luc kiem tra ben duoi — khong
|
||||
rem chi truoc luc chay app. Moi cho bao loi (echo + pause) ben duoi tu hien
|
||||
rem lai cua so truoc khi in, de thong bao van doc duoc.
|
||||
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
|
||||
if exist "%CONSOLE_VIS%" (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
|
||||
)
|
||||
|
||||
rem --------------------------------------------------------------------------
|
||||
rem 1. Chon trinh thong dich
|
||||
rem
|
||||
@@ -38,6 +46,7 @@ if exist "%VENV%\Scripts\python.exe" (
|
||||
)
|
||||
|
||||
if not defined RUNPY (
|
||||
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||
echo.
|
||||
echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã.
|
||||
echo.
|
||||
@@ -51,6 +60,7 @@ rem biet la phai chay install.bat.
|
||||
if not exist "%VENV%\Scripts\python.exe" (
|
||||
!RUNPY! -c "import PySide6" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||
echo.
|
||||
echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã.
|
||||
echo.
|
||||
@@ -94,6 +104,7 @@ if /I "%REPO_NAME%"=="cowork_local" (
|
||||
if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1
|
||||
mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||
echo.
|
||||
echo [LOI] Khong tao duoc lien ket thu muc:
|
||||
echo "!PKGPATH!\cowork_local" -> "%REPO%"
|
||||
@@ -112,6 +123,7 @@ rem Chot lai: goi phai THAT SU nhin thay duoc qua duong dan vua dung. Khong co
|
||||
rem buoc nay thi mot junction hong chi hien ra duoi dang loi Python kho hieu
|
||||
rem ("'cowork_local' is a package and cannot be directly executed").
|
||||
if not exist "!PKGPATH!\cowork_local\__main__.py" (
|
||||
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||
echo.
|
||||
echo [LOI] Khong tim thay cowork_local\__main__.py qua duong dan:
|
||||
echo "!PKGPATH!"
|
||||
@@ -139,18 +151,12 @@ set "PYTHONIOENCODING=utf-8"
|
||||
cd /d "%REPO%"
|
||||
|
||||
rem --------------------------------------------------------------------------
|
||||
rem 4. An cua so console trong luc chay
|
||||
rem 4. Chay app
|
||||
rem
|
||||
rem App la GUI (Qt), khong can console — nhung no chia se console cua chinh
|
||||
rem cmd nay (khong tu mo cua so rieng), nen cua so den cua run.bat cu the
|
||||
rem hien suot phien lam viec neu khong lam gi. An no ngay truoc khi chay, roi
|
||||
rem chi hien lai NEU app thoat loi, de thong bao loi ben duoi van doc duoc.
|
||||
rem App la GUI (Qt), khong can console — cua so console da bi an tu dau file
|
||||
rem roi (xem khoi CONSOLE_VIS phia tren), chi hien lai NEU app thoat loi, de
|
||||
rem thong bao loi ben duoi van doc duoc.
|
||||
rem --------------------------------------------------------------------------
|
||||
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
|
||||
if exist "%CONSOLE_VIS%" (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
|
||||
)
|
||||
|
||||
!RUNPY! -m cowork_local %*
|
||||
set "RC=%ERRORLEVEL%"
|
||||
|
||||
|
||||
@@ -233,6 +233,10 @@ 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.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""DF-002 (phần b) — node đang chạy HOẶC đã chạy xong không cho edit thông
|
||||
tin trong Node. Trước khi sửa, ``_LOCKED_NODE_STATUSES`` khoá cả STEP_RUNNING
|
||||
lẫn STEP_DONE, nên một bước đã chạy xong không bao giờ sửa lại được nữa.
|
||||
|
||||
Fix: chỉ khoá khi bước ĐANG chạy (STEP_RUNNING) — chạy xong rồi thì mở khoá
|
||||
trở lại. Test này chốt cả nguồn sự thật (tuple
|
||||
``co4e_workflow_crud._LOCKED_NODE_STATUSES``) lẫn hành vi ở widget
|
||||
(``StepConfigPanel.set_locked``), để không bị hồi quy về hành vi cũ.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
|
||||
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||
|
||||
from cowork_local.core.co4e import (
|
||||
STEP_DONE, STEP_ERROR, STEP_IDLE, STEP_PLANNED, STEP_RUNNING,
|
||||
)
|
||||
from cowork_local.presentation.co4e.co4e_workflow_crud import _LOCKED_NODE_STATUSES
|
||||
from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
def test_only_running_status_is_locked() -> None:
|
||||
"""Một bước đã chạy xong (STEP_DONE) phải sửa lại được — chỉ bước đang
|
||||
thực sự chạy (STEP_RUNNING) mới bị khoá."""
|
||||
assert _LOCKED_NODE_STATUSES == (STEP_RUNNING,)
|
||||
assert STEP_DONE not in _LOCKED_NODE_STATUSES
|
||||
assert STEP_IDLE not in _LOCKED_NODE_STATUSES
|
||||
assert STEP_ERROR not in _LOCKED_NODE_STATUSES
|
||||
assert STEP_PLANNED not in _LOCKED_NODE_STATUSES
|
||||
|
||||
|
||||
def test_set_locked_disables_then_reenables_edit_fields(qapp) -> None:
|
||||
panel = StepConfigPanel()
|
||||
panel.setEnabled(True) # panel starts disabled until a step is loaded
|
||||
|
||||
panel.set_locked(True)
|
||||
assert not panel.label_edit.isEnabled()
|
||||
assert not panel.instructions_edit.isEnabled()
|
||||
assert not panel.model_combo.isEnabled()
|
||||
|
||||
panel.set_locked(False)
|
||||
assert panel.label_edit.isEnabled()
|
||||
assert panel.instructions_edit.isEnabled()
|
||||
assert panel.model_combo.isEnabled()
|
||||
@@ -1,9 +1,11 @@
|
||||
"""DF-006 — the "Số dòng/trang" (rows per page) control: EventTable's
|
||||
page-size state (presentation/monitoring/shared/event_table.py) and its
|
||||
QComboBox wiring in build_filter_scaffold (.../shared/filter_scaffold.py).
|
||||
No dedicated test existed for this before — the design called for a
|
||||
user-visible/choosable item-per-page control, and this exercises it end to
|
||||
end (combo selection -> EventTable actually re-trimming its rows)."""
|
||||
"""DF-006 — the "Số dòng/trang" (rows per page) control plus real prev/next
|
||||
pagination: EventTable's page-size/page-index state
|
||||
(presentation/monitoring/shared/event_table.py) and its QComboBox + pager
|
||||
button wiring in build_filter_scaffold (.../shared/filter_scaffold.py).
|
||||
|
||||
Options are 5/10/20/50/100 with a next/prev pager, per the QA follow-up on
|
||||
DF-006 — the earlier fix only trimmed to a page size (dropping every row past
|
||||
it with no way back); this exercises the real paging end to end."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -32,19 +34,62 @@ def _events(n: int):
|
||||
"name": f"e{i}", "ok": True, "detail": ""} for i in range(n)]
|
||||
|
||||
|
||||
def test_default_page_size_matches_old_max_rows(qapp) -> None:
|
||||
table = EventTable()
|
||||
assert table.page_size() == 300
|
||||
table.set_events(_events(500))
|
||||
assert table.rowCount() == 300
|
||||
def test_page_size_options_are_5_10_20_50_100() -> None:
|
||||
assert PAGE_SIZE_OPTIONS == (5, 10, 20, 50, 100)
|
||||
|
||||
|
||||
def test_set_page_size_retrims_without_reloading(qapp) -> None:
|
||||
def test_default_page_size(qapp) -> None:
|
||||
table = EventTable()
|
||||
table.set_events(_events(500))
|
||||
assert table.page_size() == 20
|
||||
table.set_events(_events(45))
|
||||
assert table.rowCount() == 20
|
||||
assert table.page_count() == 3
|
||||
assert table.current_page() == 0
|
||||
|
||||
|
||||
def test_set_page_size_resets_to_first_page(qapp) -> None:
|
||||
table = EventTable()
|
||||
table.set_events(_events(45))
|
||||
table.next_page()
|
||||
assert table.current_page() == 1
|
||||
table.set_page_size(50)
|
||||
assert table.page_size() == 50
|
||||
assert table.rowCount() == 50
|
||||
assert table.current_page() == 0
|
||||
assert table.rowCount() == 45 # only 45 events total, fits in one page of 50
|
||||
|
||||
|
||||
def test_next_prev_page_navigate_without_dropping_rows(qapp) -> None:
|
||||
table = EventTable()
|
||||
table.set_events(_events(45))
|
||||
table.set_page_size(20)
|
||||
assert table.rowCount() == 20
|
||||
|
||||
table.next_page()
|
||||
assert table.current_page() == 1
|
||||
assert table.rowCount() == 20
|
||||
|
||||
table.next_page()
|
||||
assert table.current_page() == 2
|
||||
assert table.rowCount() == 5 # last page: remainder
|
||||
|
||||
table.next_page() # already on last page — stays put
|
||||
assert table.current_page() == 2
|
||||
|
||||
table.prev_page()
|
||||
assert table.current_page() == 1
|
||||
table.prev_page()
|
||||
table.prev_page() # already on first page — stays put
|
||||
assert table.current_page() == 0
|
||||
|
||||
|
||||
def test_page_changed_signal_reports_current_and_total(qapp) -> None:
|
||||
table = EventTable()
|
||||
seen = []
|
||||
table.page_changed.connect(lambda cur, total: seen.append((cur, total)))
|
||||
table.set_events(_events(45))
|
||||
table.set_page_size(20)
|
||||
table.next_page()
|
||||
assert seen[-1] == (1, 3)
|
||||
|
||||
|
||||
def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
|
||||
@@ -52,19 +97,44 @@ def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
|
||||
table = EventTable()
|
||||
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=False)
|
||||
assert "page_size_combo" not in parts
|
||||
assert "page_prev_btn" not in parts
|
||||
|
||||
|
||||
def test_page_size_combo_changes_the_table(qapp) -> None:
|
||||
page = QWidget()
|
||||
table = EventTable()
|
||||
table.set_events(_events(500))
|
||||
table.set_events(_events(45))
|
||||
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
|
||||
combo = parts["page_size_combo"]
|
||||
assert combo.count() == len(PAGE_SIZE_OPTIONS)
|
||||
assert combo.currentData() == 300 # matches EventTable's current page_size
|
||||
assert combo.currentData() == 20 # matches EventTable's current page_size
|
||||
|
||||
idx = PAGE_SIZE_OPTIONS.index(50)
|
||||
idx = PAGE_SIZE_OPTIONS.index(10)
|
||||
combo.setCurrentIndex(idx)
|
||||
|
||||
assert table.page_size() == 50
|
||||
assert table.rowCount() == 50
|
||||
assert table.page_size() == 10
|
||||
assert table.rowCount() == 10
|
||||
|
||||
|
||||
def test_pager_buttons_disable_at_bounds_and_indicator_updates(qapp) -> None:
|
||||
page = QWidget()
|
||||
table = EventTable()
|
||||
table.set_events(_events(45))
|
||||
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
|
||||
table.set_page_size(20)
|
||||
prev_btn, next_btn = parts["page_prev_btn"], parts["page_next_btn"]
|
||||
indicator = parts["page_indicator_label"]
|
||||
|
||||
assert not prev_btn.isEnabled()
|
||||
assert next_btn.isEnabled()
|
||||
assert indicator.text() == "Trang 1/3"
|
||||
|
||||
next_btn.click()
|
||||
assert prev_btn.isEnabled()
|
||||
assert next_btn.isEnabled()
|
||||
assert indicator.text() == "Trang 2/3"
|
||||
|
||||
next_btn.click()
|
||||
assert prev_btn.isEnabled()
|
||||
assert not next_btn.isEnabled()
|
||||
assert indicator.text() == "Trang 3/3"
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Công tắc "Chặn mạng cho lệnh do agent chạy" phải chặn MỌI đường ra mạng của
|
||||
agent, không riêng ``run_command``.
|
||||
|
||||
Trước đây ``block_network`` chỉ được đọc ở đúng một chỗ —
|
||||
``infrastructure/filesystem/command_tools.py`` trong ``run_command`` — nên bốn
|
||||
tool mang ``ToolCapability.NETWORK`` (``fetch_url``, ``jira_search``,
|
||||
``jira_get_issue``, ``install_package``) vẫn ra internet bình thường trong khi
|
||||
màn Monitoring báo "Mạng: Bị chặn" và docstring của ``fetch_tools`` tự nhận là
|
||||
*"Honors the Sandbox Security Layer's Block network policy"*. Người dùng bật
|
||||
công tắc rồi thấy agent vẫn search web được — đúng triệu chứng được báo.
|
||||
|
||||
Hai nhóm bài:
|
||||
|
||||
* **hành vi** — bật thì mọi tool NETWORK từ chối TRƯỚC khi chạm mạng, tắt thì
|
||||
đường cũ giữ nguyên (chặn một chiều là hỏng tính năng);
|
||||
* **guardrail** — thêm tool mạng mới mà quên chặn thì bài ở đây đỏ ngay.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core.tools import ToolContext
|
||||
from cowork_local.domain.tools import BUILT_IN_CAPABILITIES, ToolCapability
|
||||
from cowork_local.infrastructure.filesystem import command_tools, fetch_tools
|
||||
|
||||
# tên tool -> (handler, args hợp lệ tối thiểu). Args phải hợp lệ, nếu không bài
|
||||
# test sẽ đỏ vì lỗi thiếu tham số chứ không vì cổng chặn mạng.
|
||||
_TOOL_MANG: Dict[str, tuple] = {
|
||||
"fetch_url": (fetch_tools.fetch_url, {"url": "https://example.com/"}),
|
||||
"jira_search": (fetch_tools.jira_search, {"jql": "project = ABC"}),
|
||||
"jira_get_issue": (fetch_tools.jira_get_issue, {"key": "ABC-1"}),
|
||||
"install_package": (command_tools.install_package, {"package": "requests"}),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cam_ra_mang(monkeypatch):
|
||||
"""Mọi đường ra mạng thật đều nổ.
|
||||
|
||||
Vừa giữ cho bộ test không chạm internet, vừa làm lộ tool nào lọt qua cổng
|
||||
chặn: nó sẽ đỏ ngay tại lời gọi mạng thay vì im lặng đi ra ngoài.
|
||||
"""
|
||||
def no_ra_mang(*args, **kwargs):
|
||||
raise AssertionError("tool đã chạm mạng dù 'Chặn mạng' đang bật")
|
||||
|
||||
from cowork_local.core import deps, jira_tool, link_fetch
|
||||
|
||||
monkeypatch.setattr(link_fetch, "fetch_link_preview", no_ra_mang)
|
||||
monkeypatch.setattr(jira_tool, "search", no_ra_mang)
|
||||
monkeypatch.setattr(jira_tool, "get_issue", no_ra_mang)
|
||||
monkeypatch.setattr(jira_tool, "get_issue_by_url", no_ra_mang)
|
||||
monkeypatch.setattr(deps, "pip_install", no_ra_mang)
|
||||
|
||||
|
||||
# ---- hành vi: bật công tắc thì mọi tool mạng đều bị chặn -----------------
|
||||
|
||||
@pytest.mark.parametrize("ten", sorted(_TOOL_MANG))
|
||||
def test_bat_chan_mang_thi_tool_tu_choi_truoc_khi_cham_mang(tmp_path, cam_ra_mang, ten):
|
||||
"""Đây là chính triệu chứng người dùng báo: bật rồi mà vẫn ra được web."""
|
||||
handler, args = _TOOL_MANG[ten]
|
||||
ctx = ToolContext(tmp_path, block_network=True)
|
||||
|
||||
ket_qua = handler(ctx, args)
|
||||
|
||||
assert ket_qua["ok"] is False, f"{ten} vẫn chạy khi đang chặn mạng"
|
||||
assert "Sandbox Security Layer" in ket_qua["output"], ket_qua["output"]
|
||||
|
||||
|
||||
def test_allow_url_fetch_khong_lach_duoc_chan_mang(tmp_path, cam_ra_mang):
|
||||
"""Hai công tắc vẫn độc lập, nhưng "Chặn mạng" là cái mạnh hơn: bật nó thì
|
||||
"Cho phép agent lấy dữ liệu từ URL" không mở lại đường được."""
|
||||
ctx = ToolContext(tmp_path, block_network=True, allow_url_fetch=True)
|
||||
|
||||
ket_qua = fetch_tools.fetch_url(ctx, {"url": "https://example.com/"})
|
||||
|
||||
assert ket_qua["ok"] is False
|
||||
|
||||
|
||||
# ---- hành vi: tắt công tắc thì đường cũ giữ nguyên -----------------------
|
||||
|
||||
def test_tat_chan_mang_thi_fetch_url_van_doc_duoc(tmp_path, monkeypatch):
|
||||
"""Chặn một chiều là hỏng tính năng — cổng phải mở lại được."""
|
||||
from cowork_local.core import link_fetch
|
||||
|
||||
monkeypatch.setattr(link_fetch, "fetch_link_preview",
|
||||
lambda url: f"nội dung của {url}")
|
||||
ctx = ToolContext(tmp_path, block_network=False)
|
||||
|
||||
ket_qua = fetch_tools.fetch_url(ctx, {"url": "https://example.com/"})
|
||||
|
||||
assert ket_qua["ok"] is True
|
||||
assert "example.com" in ket_qua["output"]
|
||||
|
||||
|
||||
def test_tat_chan_mang_thi_install_package_van_chay(tmp_path, monkeypatch):
|
||||
from cowork_local.core import deps
|
||||
|
||||
da_goi = []
|
||||
|
||||
def gia_lap_pip(package, **kwargs):
|
||||
da_goi.append(package)
|
||||
return True, "ok"
|
||||
|
||||
monkeypatch.setattr(deps, "pip_install", gia_lap_pip)
|
||||
ctx = ToolContext(tmp_path, block_network=False)
|
||||
|
||||
ket_qua = command_tools.install_package(ctx, {"package": "requests"})
|
||||
|
||||
assert da_goi == ["requests"]
|
||||
assert ket_qua["ok"] is True
|
||||
|
||||
|
||||
# ---- guardrail: danh sách tool mạng không được lệch ----------------------
|
||||
|
||||
def test_moi_tool_mang_deu_co_bai_o_day():
|
||||
"""``BUILT_IN_CAPABILITIES`` là nơi duy nhất khai báo tool nào chạm mạng.
|
||||
Thêm một tool NETWORK mới mà quên chặn thì bài này đỏ ngay."""
|
||||
tag_mang = {ten for ten, cap in BUILT_IN_CAPABILITIES.items()
|
||||
if cap & ToolCapability.NETWORK}
|
||||
|
||||
assert tag_mang == set(_TOOL_MANG), (
|
||||
"danh sách tool mạng đã đổi — chặn tool mới ở cổng block_network "
|
||||
"rồi bổ sung vào _TOOL_MANG")
|
||||
@@ -0,0 +1,135 @@
|
||||
"""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}"
|
||||
@@ -0,0 +1,128 @@
|
||||
"""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
|
||||
@@ -0,0 +1,223 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Cổng project: Cowork và GraphRAG chỉ hiện khi đã chọn một project cụ thể.
|
||||
|
||||
Cổng có hai mặt và trước đây chỉ mặt thứ nhất làm đúng:
|
||||
|
||||
* **Sub-tab trong màn Workspace** — ``_update_tab_visibility`` vốn đã ẩn/hiện
|
||||
đúng. Chỗ hỏng nằm ở ``refresh()``: nó mặc định ``row_to_select = 0`` nên lúc
|
||||
mở app (chưa ai bấm gì) danh sách tự chọn hộ project đầu tiên, mở cổng cho một
|
||||
project người dùng chưa hề chọn.
|
||||
* **Hàng trên menu trái** — ``NavRailMixin._rebuild_nav`` từng dựng hàng ở dạng
|
||||
mờ kèm tooltip thay vì bỏ đi ("shown instead of hidden"), nên người dùng vẫn
|
||||
thấy Cowork/GraphRAG trên menu dù cổng đang đóng.
|
||||
|
||||
Các bài dưới đây chốt cả hai mặt, ở cả ba trạng thái: chưa chọn → ẩn, chọn rồi →
|
||||
hiện, bỏ chọn → ẩn lại.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def win(qapp, tmp_path):
|
||||
"""MainWindow thật — cần cả cửa sổ vì phải kiểm cả menu trái.
|
||||
|
||||
Đọc project từ ``~/.cowork_local`` như bản cài thật (``core/projects.py``
|
||||
gắn ``PROJECTS_DIR`` vào đó) nên các bài này KHÔNG tạo/xoá project nào.
|
||||
Bài nào cần cổng MỞ thì gọi thẳng ``_update_tab_visibility(True)`` thay vì
|
||||
tạo project trên đĩa của người chạy test.
|
||||
"""
|
||||
from cowork_local.presentation.shell.bootstrap import build_config, build_context
|
||||
from cowork_local.presentation.shell.main_window import MainWindow
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
build_config(config_path)
|
||||
window = MainWindow(build_context(config_path))
|
||||
yield window
|
||||
window.close()
|
||||
|
||||
|
||||
def _cong(ws):
|
||||
"""Hai sub-tab nằm sau cổng project, bỏ qua bản dựng không có chúng."""
|
||||
return [(ten, idx) for ten, idx in
|
||||
(("Cowork", ws._cowork_tab_idx), ("GraphRAG", ws._graphrag_tab_idx))
|
||||
if idx >= 0]
|
||||
|
||||
|
||||
def _hang_menu(win):
|
||||
"""Nhãn của mọi hàng đang có trên cột menu trái."""
|
||||
return [win.nav.topLevelItem(i).text(0) for i in range(win.nav.topLevelItemCount())]
|
||||
|
||||
|
||||
# ---- mặt 1: không tự chọn hộ project ------------------------------------
|
||||
|
||||
def test_mo_app_len_chua_chon_thi_khong_tu_chon_ho(win):
|
||||
"""Đây là nguyên nhân gốc: ``refresh()`` từng mặc định chọn dòng 0."""
|
||||
ws = win.workspace
|
||||
|
||||
assert ws._current_id == ""
|
||||
assert ws.project_list.currentRow() == -1
|
||||
|
||||
|
||||
def test_chua_chon_project_thi_hai_sub_tab_deu_an(win):
|
||||
ws = win.workspace
|
||||
|
||||
for ten, idx in _cong(ws):
|
||||
assert ws.tabs.isTabVisible(idx) is False, f"{ten} hiện khi chưa chọn project"
|
||||
assert ws.subtab_available(idx) is False, f"{ten} vẫn mở cổng"
|
||||
|
||||
|
||||
def test_chua_chon_project_thi_dung_o_tab_project(win):
|
||||
"""Ẩn hai tab kia mà lại đứng ở một tab đã ẩn thì màn hình trống trơn."""
|
||||
ws = win.workspace
|
||||
|
||||
assert ws.current_subtab() == ws._project_tab_idx
|
||||
|
||||
|
||||
def test_refresh_giu_nguyen_project_dang_chon(win):
|
||||
"""Sửa cổng không được làm mất lựa chọn hiện có: ``keep`` vẫn phải thắng."""
|
||||
ws = win.workspace
|
||||
if ws.project_list.count() == 0:
|
||||
pytest.skip("máy chạy test chưa có project nào để chọn")
|
||||
|
||||
ws.project_list.setCurrentRow(0)
|
||||
dang_chon = ws._current_id
|
||||
|
||||
ws.refresh()
|
||||
|
||||
assert ws._current_id == dang_chon
|
||||
assert ws.project_list.currentRow() >= 0
|
||||
|
||||
|
||||
# ---- mặt 2: menu trái bỏ hẳn hàng, không hiện dạng mờ -------------------
|
||||
|
||||
def test_chua_chon_project_thi_menu_trai_khong_co_hai_hang(win):
|
||||
"""Đây là thứ người dùng nhìn thấy — trước đây hai hàng vẫn nằm đó, chỉ mờ."""
|
||||
nhan = _hang_menu(win)
|
||||
|
||||
assert "Cowork" not in nhan, f"Cowork vẫn trên menu: {nhan}"
|
||||
assert "GraphRAG" not in nhan, f"GraphRAG vẫn trên menu: {nhan}"
|
||||
|
||||
|
||||
def test_mo_cong_thi_hai_hang_quay_lai_menu_trai(win):
|
||||
"""Bỏ hàng phải đảo ngược được, nếu không thì chọn project xong vẫn kẹt."""
|
||||
ws = win.workspace
|
||||
ws._current_id = "gia-lap"
|
||||
ws._update_tab_visibility(True)
|
||||
|
||||
nhan = _hang_menu(win)
|
||||
assert "Cowork" in nhan, f"Cowork không quay lại: {nhan}"
|
||||
assert "GraphRAG" in nhan, f"GraphRAG không quay lại: {nhan}"
|
||||
|
||||
|
||||
def test_mo_cong_thi_hai_sub_tab_cung_hien_lai(win):
|
||||
ws = win.workspace
|
||||
ws._current_id = "gia-lap"
|
||||
ws._update_tab_visibility(True)
|
||||
|
||||
for ten, idx in _cong(ws):
|
||||
assert ws.tabs.isTabVisible(idx) is True, f"{ten} vẫn ẩn khi cổng đã mở"
|
||||
|
||||
|
||||
def test_dong_cong_lai_thi_hai_hang_bien_mat(win):
|
||||
"""Cổng phải đóng lại được, không chỉ mở một chiều."""
|
||||
ws = win.workspace
|
||||
ws._current_id = "gia-lap"
|
||||
ws._update_tab_visibility(True)
|
||||
ws._current_id = ""
|
||||
ws._update_tab_visibility(False)
|
||||
|
||||
nhan = _hang_menu(win)
|
||||
assert "Cowork" not in nhan and "GraphRAG" not in nhan, nhan
|
||||
|
||||
|
||||
def test_cac_hang_khac_khong_bi_anh_huong(win):
|
||||
"""Chỉ hai hàng sau cổng bị bỏ — phần còn lại của menu giữ nguyên."""
|
||||
nhan = _hang_menu(win)
|
||||
|
||||
for bat_buoc in ("Project", "Co4E"):
|
||||
assert bat_buoc in nhan, f"{bat_buoc} biến mất khỏi menu: {nhan}"
|
||||
@@ -1,23 +1,21 @@
|
||||
"""Sandbox Security unlock — chốt các đường KHÔNG được mở khoá (SEC-20260907-01).
|
||||
"""Sandbox Security Layer: bốn công tắc luôn sửa được, không còn khoá mật khẩu.
|
||||
|
||||
``DEFAULT_CONFIG`` ship ``agent_security.sandbox_pw = ""`` kể từ commit
|
||||
``3827552 fix(security): remove shared unlock defaults``, và cấu hình đưa tới
|
||||
dialog LUÔN được deep-merge với defaults đó
|
||||
(``infrastructure/config/json_config_repository.py``). Nghĩa là trên mọi bản cài
|
||||
không đặt ``COWORK_SANDBOX_PASSWORD``, mật khẩu đã lưu là chuỗi rỗng — và phép so
|
||||
sánh ``pw == self._sandbox_pw`` nhận luôn ô nhập trống.
|
||||
Trước đây nhóm này bị khoá: bốn công tắc dựng ra ở trạng thái ``setEnabled(False)``
|
||||
và chỉ mở khi nhập đúng mật khẩu qua ``_sandbox_unlock()``. Bộ bài cũ ở file này
|
||||
(SEC-20260907-01) chốt các đường KHÔNG được mở khoá — chúng mất đối tượng kiểm khi
|
||||
tính năng khoá bị bỏ theo yêu cầu, nên được thay bằng các bài dưới đây.
|
||||
|
||||
Ba nhóm bài ở đây:
|
||||
Docstring của ``_sandbox_unlock()`` cũ đã tự nói rõ nó là gì: *"khoá phía giao diện
|
||||
để chặn bấm nhầm vào một mục nhạy cảm, KHÔNG phải cơ chế bảo mật thật"*. Rào thật
|
||||
nằm ở tầng sandbox lúc chạy lệnh, không ở hộp thoại Cài đặt.
|
||||
|
||||
* **đường tấn công** — chốt đúng lỗ trên;
|
||||
* **đường đi đúng** — bản vá không được phá, kể cả với mật khẩu có dấu;
|
||||
* **chặn cả lớp lỗi** — commit ``3827552`` sửa ``config.py`` nhưng bỏ sót bản sao
|
||||
thứ hai của literal trong ``ui/settings_dialog.py``. Bài cuối quét chéo mọi thư
|
||||
mục nguồn để lần sau không sót kiểu đó nữa.
|
||||
Hai nhóm bài:
|
||||
|
||||
* **hành vi mới** — mở hộp thoại là bật/tắt được ngay, không qua bước nào;
|
||||
* **guardrail** — quét mã nguồn để lần sau không ai lặng lẽ khoá lại.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -27,209 +25,76 @@ import pytest
|
||||
from .test_settings_dialog_dac_ta import _Ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shown(monkeypatch):
|
||||
"""Ghi lại mọi QMessageBox thay vì bật modal thật (modal sẽ treo test).
|
||||
|
||||
Trả về list các ``(loại, tiêu_đề, nội_dung)`` — cần thiết để phân biệt
|
||||
"chưa cấu hình mật khẩu" với "sai mật khẩu"; nếu chỉ nuốt hộp thoại đi thì
|
||||
hai nhánh gộp lại làm một mà test vẫn xanh.
|
||||
"""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
calls: list[tuple[str, str, str]] = []
|
||||
|
||||
def _record(kind):
|
||||
def _fn(_parent, title, text, *a, **k):
|
||||
calls.append((kind, title, text))
|
||||
return staticmethod(_fn)
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "warning", _record("warning"))
|
||||
monkeypatch.setattr(QMessageBox, "information", _record("information"))
|
||||
return calls
|
||||
|
||||
|
||||
def _dialog(stored_pw: str):
|
||||
"""SettingsDialog với ``sandbox_pw`` đúng như bản cài thật: key CÓ mặt."""
|
||||
def _dialog():
|
||||
"""SettingsDialog dựng đúng như bản cài thật."""
|
||||
from cowork_local.ui.settings_dialog import SettingsDialog
|
||||
ctx = _Ctx()
|
||||
ctx.config.data["agent_security"]["sandbox_pw"] = stored_pw
|
||||
return SettingsDialog(ctx)
|
||||
return SettingsDialog(_Ctx())
|
||||
|
||||
|
||||
# ---- đường tấn công ------------------------------------------------------
|
||||
|
||||
def test_o_trong_khong_mo_duoc_khoa(qapp, shown):
|
||||
"""Chưa đặt mật khẩu (sandbox_pw == "") thì ô nhập trống KHÔNG được mở khoá."""
|
||||
dlg = _dialog("")
|
||||
dlg.sandbox_pw_edit.setText("")
|
||||
|
||||
dlg._sandbox_unlock()
|
||||
|
||||
assert dlg._sandbox_unlocked is False
|
||||
dlg.deleteLater()
|
||||
_CONG_TAC = ("sandbox_confirm", "sandbox_block_network", "sec_enabled", "ai_check")
|
||||
|
||||
|
||||
def test_go_bua_khi_chua_dat_mat_khau_cung_khong_mo_duoc(qapp, shown):
|
||||
"""Mật khẩu lưu rỗng thì KHÔNG chuỗi nào mở được, kể cả chuỗi khác rỗng."""
|
||||
dlg = _dialog("")
|
||||
dlg.sandbox_pw_edit.setText("bat ky")
|
||||
# ---- hành vi mới: sửa được ngay, không cần mật khẩu ----------------------
|
||||
|
||||
dlg._sandbox_unlock()
|
||||
@pytest.mark.parametrize("ten", _CONG_TAC)
|
||||
def test_cong_tac_sua_duoc_ngay_khi_mo_hop_thoai(qapp, ten):
|
||||
"""Đây là chính yêu cầu: không còn bước nhập mật khẩu nào chắn ở giữa."""
|
||||
dlg = _dialog()
|
||||
|
||||
assert dlg._sandbox_unlocked is False
|
||||
dlg.deleteLater()
|
||||
assert getattr(dlg, ten).isEnabled() is True, f"{ten} vẫn bị khoá"
|
||||
|
||||
|
||||
def test_mat_khau_sai_khong_mo_duoc(qapp, shown):
|
||||
"""Đã đặt mật khẩu thì gõ sai vẫn khoá."""
|
||||
dlg = _dialog("K7MNP2QRSTVW")
|
||||
dlg.sandbox_pw_edit.setText("K7MNP2QRSTVX")
|
||||
@pytest.mark.parametrize("ten", _CONG_TAC)
|
||||
def test_bat_tat_duoc_va_luu_dung_gia_tri(qapp, ten):
|
||||
"""Bật/tắt phải ăn vào widget — khoá cũ chặn đúng ở bước này."""
|
||||
dlg = _dialog()
|
||||
w = getattr(dlg, ten)
|
||||
|
||||
dlg._sandbox_unlock()
|
||||
|
||||
assert dlg._sandbox_unlocked is False
|
||||
dlg.deleteLater()
|
||||
truoc = w.isChecked()
|
||||
w.setChecked(not truoc)
|
||||
assert w.isChecked() is (not truoc)
|
||||
w.setChecked(truoc)
|
||||
assert w.isChecked() is truoc
|
||||
|
||||
|
||||
# ---- thông báo phải phân biệt được hai tình huống -------------------------
|
||||
def test_khong_con_widget_mat_khau_nao(qapp):
|
||||
"""Ô nhập, nút Mở khoá và nhãn "Đang khoá" phải biến mất khỏi hộp thoại."""
|
||||
dlg = _dialog()
|
||||
|
||||
def test_chua_cau_hinh_bao_khac_voi_sai_mat_khau(qapp, shown):
|
||||
"""Hai nhánh phải nói hai chuyện khác nhau.
|
||||
|
||||
Người chưa từng đặt mật khẩu mà nhận "Password incorrect" sẽ gõ lại mãi một
|
||||
thứ không tồn tại. Không có bài này thì gộp hai nhánh về một thông báo chung
|
||||
vẫn xanh hết.
|
||||
"""
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
dlg = _dialog("")
|
||||
dlg.sandbox_pw_edit.setText("")
|
||||
dlg._sandbox_unlock()
|
||||
chua_cau_hinh = list(shown)
|
||||
dlg.deleteLater()
|
||||
|
||||
shown.clear()
|
||||
dlg2 = _dialog("K7MNP2QRSTVW")
|
||||
dlg2.sandbox_pw_edit.setText("sai roi")
|
||||
dlg2._sandbox_unlock()
|
||||
sai_mat_khau = list(shown)
|
||||
dlg2.deleteLater()
|
||||
|
||||
assert len(chua_cau_hinh) == 1, "phải hiện đúng một thông báo"
|
||||
assert len(sai_mat_khau) == 1
|
||||
assert chua_cau_hinh[0][2] == tr("settings.sandbox_pw_unset_body")
|
||||
assert chua_cau_hinh[0][2] != sai_mat_khau[0][2], (
|
||||
"chưa cấu hình mật khẩu và sai mật khẩu phải là hai thông báo khác nhau")
|
||||
for ten in ("sandbox_pw_edit", "sandbox_unlock_btn", "sandbox_locked_status",
|
||||
"sandbox_pw_label"):
|
||||
assert not hasattr(dlg, ten), f"{ten} vẫn còn trên hộp thoại"
|
||||
|
||||
|
||||
# ---- đường đi đúng vẫn phải chạy ----------------------------------------
|
||||
def test_khong_con_duong_mo_khoa_trong_ma(qapp):
|
||||
"""Hàm mở khoá và cờ trạng thái khoá không còn tồn tại."""
|
||||
import cowork_local.ui.settings_dialog as mod
|
||||
|
||||
def test_mat_khau_dung_van_mo_duoc(qapp, shown):
|
||||
"""Bản vá không được phá đường đi hợp lệ."""
|
||||
dlg = _dialog("K7MNP2QRSTVW")
|
||||
dlg.sandbox_pw_edit.setText("K7MNP2QRSTVW")
|
||||
|
||||
dlg._sandbox_unlock()
|
||||
|
||||
assert dlg._sandbox_unlocked is True
|
||||
dlg.deleteLater()
|
||||
dlg = _dialog()
|
||||
assert not hasattr(dlg, "_sandbox_unlock")
|
||||
assert not hasattr(dlg, "_sandbox_unlocked")
|
||||
assert not hasattr(dlg, "_sandbox_widgets")
|
||||
assert not hasattr(mod, "_sandbox_password_matches")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pw", ["mật khẩu", "パスワード", "sénhà-2026"])
|
||||
def test_mat_khau_co_dau_khong_lam_crash(qapp, shown, pw):
|
||||
"""``secrets.compare_digest`` ném TypeError nếu str có ký tự ngoài ASCII.
|
||||
# ---- guardrail: không ai khoá lại mà không sửa bài test này --------------
|
||||
|
||||
App mặc định tiếng Việt và phục vụ khách Nhật, nên chữ có dấu trong ô mật
|
||||
khẩu là input bình thường. Phải so sánh trên bytes.
|
||||
"""
|
||||
dlg = _dialog(pw)
|
||||
dlg.sandbox_pw_edit.setText(pw)
|
||||
def test_ma_nguon_khong_con_khoa_nhom_sandbox():
|
||||
"""Chặn cả lớp lỗi: lần sau ai thêm lại ``setEnabled(False)`` cho nhóm này
|
||||
thì bài này đỏ ngay, không đợi có người mở app mới thấy."""
|
||||
src = (Path(__file__).resolve().parents[2]
|
||||
/ "ui" / "settings_dialog.py").read_text(encoding="utf-8")
|
||||
code = "\n".join(l for l in src.splitlines() if not l.strip().startswith("#"))
|
||||
|
||||
dlg._sandbox_unlock() # không được ném TypeError
|
||||
|
||||
assert dlg._sandbox_unlocked is True
|
||||
dlg.deleteLater()
|
||||
for dau_hieu in ("_sandbox_unlock", "_sandbox_widgets", "_sandbox_unlocked",
|
||||
"sandbox_pw"):
|
||||
assert dau_hieu not in code, f"khoá sandbox đã quay lại: {dau_hieu}"
|
||||
|
||||
|
||||
def test_mat_khau_co_dau_sai_thi_van_khoa(qapp, shown):
|
||||
"""Chữ có dấu không được biến thành đường mở khoá dễ dãi."""
|
||||
dlg = _dialog("mật khẩu")
|
||||
dlg.sandbox_pw_edit.setText("mat khau")
|
||||
def test_phep_quet_thuc_su_doc_duoc_file():
|
||||
"""Lưới an toàn: đổi tên file làm bài trên quét rỗng mà vẫn xanh."""
|
||||
src = (Path(__file__).resolve().parents[2]
|
||||
/ "ui" / "settings_dialog.py").read_text(encoding="utf-8")
|
||||
|
||||
dlg._sandbox_unlock()
|
||||
|
||||
assert dlg._sandbox_unlocked is False
|
||||
dlg.deleteLater()
|
||||
|
||||
|
||||
# ---- hàm so khớp, gọi thẳng ----------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("entered,stored,expected", [
|
||||
("", "", False), # cả hai rỗng
|
||||
("", "K7MNP2QRSTVW", False), # ô nhập rỗng
|
||||
("K7MNP2QRSTVW", "", False), # chưa đặt mật khẩu — nhánh phòng thủ
|
||||
("K7MNP2QRSTVW", "K7MNP2QRSTVW", True),
|
||||
("mật khẩu", "mật khẩu", True), # ngoài ASCII
|
||||
("mật khẩu", "mat khau", False),
|
||||
])
|
||||
def test_ham_so_khop(entered, stored, expected):
|
||||
"""Gọi thẳng ``_sandbox_password_matches`` — phủ cả nhánh mà call site đã
|
||||
chặn trước bằng return sớm."""
|
||||
from cowork_local.ui.settings_dialog import _sandbox_password_matches
|
||||
assert _sandbox_password_matches(entered, stored) is expected
|
||||
|
||||
|
||||
# ---- chặn cả lớp lỗi -----------------------------------------------------
|
||||
|
||||
#: ``.get("<khoá kiểu credential>", "<literal khác rỗng>")`` — mặc định trông có
|
||||
#: vẻ an toàn nhưng thực ra là credential nằm trong mã nguồn. Nó cũng là code
|
||||
#: chết: cấu hình đã deep-merge với DEFAULT_CONFIG nên key luôn tồn tại.
|
||||
#:
|
||||
#: Cố ý KHÔNG bắt ``key`` và ``code`` trần: ``it.get("key", "?")`` của Jira
|
||||
#: (``core/jira_tool.py``) là mã issue, không phải credential. Danh sách dưới đây
|
||||
#: chỉ gồm tên đã mang nghĩa bí mật.
|
||||
_CREDENTIAL_FALLBACK = re.compile(
|
||||
r'\.get\(\s*["\'][a-z_]*'
|
||||
r'(?:pw|passwd|password|secret|token|api_key|unlock_code|access_code)'
|
||||
r'[a-z_]*["\']\s*,\s*["\'][^"\']+["\']'
|
||||
)
|
||||
|
||||
#: Quét CHÉO mọi thư mục nguồn, không chỉ tầng giao diện. Sai sót gốc của commit
|
||||
#: ``3827552`` là sửa ``config.py`` mà quên bản sao trong ``ui/`` — tức là lỗi đi
|
||||
#: xuyên thư mục, nên phép quét cũng phải đi xuyên thư mục.
|
||||
_SCANNED = (
|
||||
"ui", "presentation", "core", "infrastructure", "application", "domain",
|
||||
"mcp_servers", "providers", "security", "theme", "config.py", "state.py",
|
||||
)
|
||||
|
||||
|
||||
def test_khong_con_fallback_credential_trong_ma_nguon():
|
||||
"""Không file nguồn nào được đặt credential làm giá trị mặc định của ``.get()``."""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
offenders = []
|
||||
for name in _SCANNED:
|
||||
target = root / name
|
||||
if target.is_file():
|
||||
files = [target]
|
||||
elif target.is_dir():
|
||||
files = [p for p in target.rglob("*.py") if "__pycache__" not in p.parts]
|
||||
else: # thư mục bị đổi tên/xoá
|
||||
continue
|
||||
for path in files:
|
||||
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if _CREDENTIAL_FALLBACK.search(line):
|
||||
offenders.append(
|
||||
f"{path.relative_to(root).as_posix()}:{lineno}: {line.strip()}")
|
||||
|
||||
assert not offenders, "credential nằm trong mã nguồn:\n " + "\n ".join(offenders)
|
||||
|
||||
|
||||
def test_phep_quet_thuc_su_nhin_thay_file():
|
||||
"""Lưới an toàn cho bài trên: đổi tên thư mục làm nó quét rỗng mà vẫn xanh."""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
seen = sum(
|
||||
1 for name in _SCANNED
|
||||
for _ in ([root / name] if (root / name).is_file()
|
||||
else (root / name).rglob("*.py") if (root / name).is_dir() else [])
|
||||
)
|
||||
assert seen > 200, f"chỉ quét được {seen} file — phạm vi quét đã hỏng"
|
||||
assert "class SettingsDialog" in src
|
||||
assert len(src) > 2000, f"chỉ đọc được {len(src)} ký tự — đường dẫn đã hỏng"
|
||||
|
||||
@@ -128,19 +128,24 @@ def test_bam_project_tren_thanh_menu_an_ngay_lan_dau(window):
|
||||
|
||||
|
||||
def test_khi_cong_project_MO_thi_ha_canh_o_cowork_va_vet_sang_theo(window):
|
||||
"""Nhánh của người dùng ĐÃ có project — nhánh mà bug được báo.
|
||||
"""Nhánh của người dùng ĐÃ chọn một project — nhánh mà bug được báo.
|
||||
|
||||
Môi trường test không có project nào (cố ý: ``core/projects.py`` ghi vào
|
||||
``~/.cowork_local`` thật). Mở cổng bằng tay để đi đúng nhánh đó mà không
|
||||
phải tạo project trên đĩa.
|
||||
Trước đây bài này mở cổng bằng cửa sau ``setTabVisible(True)`` vì môi trường
|
||||
test không có project nào (``core/projects.py`` ghi vào ``~/.cowork_local``
|
||||
thật, nên test không tạo project). Cửa sau đó hết tác dụng từ khi cổng được
|
||||
điều khiển bằng ``_current_id``: ``refresh()``/``goto_all_projects()`` đóng
|
||||
lại ngay. Giờ mở cổng bằng đúng đường thật — chọn một project — và bỏ qua
|
||||
bài này trên máy chưa có project nào.
|
||||
"""
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
ws = window.workspace
|
||||
if ws._cowork_tab_idx < 0:
|
||||
pytest.skip("bản dựng này không có sub-tab Cowork")
|
||||
if ws.project_list.count() == 0:
|
||||
pytest.skip("máy chạy test chưa có project nào — cổng không mở được")
|
||||
|
||||
ws.tabs.setTabVisible(ws._cowork_tab_idx, True)
|
||||
ws.project_list.setCurrentRow(0)
|
||||
try:
|
||||
window.goto_all_projects()
|
||||
|
||||
@@ -154,5 +159,5 @@ def test_khi_cong_project_MO_thi_ha_canh_o_cowork_va_vet_sang_theo(window):
|
||||
f"nội dung ở Cowork ({ws._cowork_tab_idx}) "
|
||||
f"nhưng thanh menu sáng ở {data.get('sub')}")
|
||||
finally:
|
||||
ws.tabs.setTabVisible(ws._cowork_tab_idx, False)
|
||||
ws.project_list.setCurrentRow(-1) # đóng cổng lại đúng đường thật
|
||||
window.goto_all_projects()
|
||||
|
||||
@@ -26,7 +26,7 @@ from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E4
|
||||
SETTINGS_FIELDS = [
|
||||
"language_combo", "theme_combo", "tray_chk", "notify_chk",
|
||||
"provider_combo", "prov_base", "prov_key", "prov_model",
|
||||
"sandbox_pw_edit", "sandbox_unlock_btn", "sandbox_confirm",
|
||||
"sandbox_confirm",
|
||||
"sandbox_block_network", "sec_enabled", "ai_check",
|
||||
]
|
||||
TASK_FIELDS = [
|
||||
|
||||
+25
-18
@@ -74,22 +74,30 @@ def main() -> int:
|
||||
|
||||
main_rows, bottom_rows = rows(win.nav), rows(win.nav_bottom)
|
||||
n_total = len(main_rows) + len(bottom_rows)
|
||||
# Five Workspace sub-views + Schedule, then Dashboard + Monitoring.
|
||||
if len(main_rows) != 6:
|
||||
fails.append(f"thanh chinh co {len(main_rows)} dong, cho 6")
|
||||
# Workspace gop cac sub-view DANG MO CONG + Schedule, roi Dashboard +
|
||||
# Monitoring. Cowork/GraphRAG chi co mat khi da chon mot project, nen so
|
||||
# dong doi theo cong thay vi co dinh 6.
|
||||
mo_cong = sum(1 for _l, _i, _ic, on in win.workspace.nav_entries() if on)
|
||||
cho_chinh = mo_cong + 1
|
||||
if len(main_rows) != cho_chinh:
|
||||
fails.append(f"thanh chinh co {len(main_rows)} dong, cho {cho_chinh}")
|
||||
if len(bottom_rows) != 2:
|
||||
fails.append(f"nhom day co {len(bottom_rows)} dong, cho 2")
|
||||
if any(sub is not None for _l, _p, sub, _o in bottom_rows):
|
||||
fails.append("nhom day khong duoc mang sub-tab")
|
||||
|
||||
# The two gated rows must be PRESENT (that is the point) — greyed is fine.
|
||||
# Hang bi cong project dong thi BO HAN khoi menu; hang dang mo phai co mat.
|
||||
labels = [r[0] for r in main_rows]
|
||||
ws_labels = [lab for lab, _i, _ic, _on in win.workspace.nav_entries()]
|
||||
for lab in ws_labels:
|
||||
ws_mo = [lab for lab, _i, _ic, on in win.workspace.nav_entries() if on]
|
||||
ws_dong = [lab for lab, _i, _ic, on in win.workspace.nav_entries() if not on]
|
||||
for lab in ws_mo:
|
||||
if lab not in labels:
|
||||
fails.append(f"mat dong Workspace: {lab}")
|
||||
print(f"du 5 man Workspace tren thanh menu: {all(l in labels for l in ws_labels)}"
|
||||
f" ({', '.join(ws_labels)})")
|
||||
fails.append(f"mat dong Workspace dang mo cong: {lab}")
|
||||
for lab in ws_dong:
|
||||
if lab in labels:
|
||||
fails.append(f"dong Workspace dang dong cong van tren menu: {lab}")
|
||||
print(f"man Workspace dang mo cong tren menu: {all(l in labels for l in ws_mo)}"
|
||||
f" ({', '.join(ws_mo) or 'khong co'})")
|
||||
|
||||
# Highlight must follow the content for every row, both ways round.
|
||||
# Re-fetch items by index every time: navigating can rebuild the rail, which
|
||||
@@ -145,19 +153,18 @@ def main() -> int:
|
||||
if ws_strip:
|
||||
fails.append("dai tab Workspace hien lai — trung voi thanh menu")
|
||||
|
||||
# The whole point of the change: with no project selected the two gated rows
|
||||
# must stay in place, greyed — not vanish and resize the menu.
|
||||
# Yeu cau: chua chon project thi Cowork/GraphRAG khong duoc hien tren menu.
|
||||
win.workspace._update_tab_visibility(False)
|
||||
app.processEvents()
|
||||
gated = rows(win.nav)
|
||||
off = [lab for lab, _p, _s, on in gated if not on]
|
||||
nhan_gated = [lab for lab, _p, _s, _on in gated]
|
||||
print()
|
||||
print(f"chua chon project : van du {len(gated)} dong, mo: {off or 'khong'}")
|
||||
if len(gated) != len(main_rows):
|
||||
fails.append(f"chua chon project thi thanh menu con {len(gated)} dong "
|
||||
f"(truoc {len(main_rows)}) — item van bien mat")
|
||||
if len(off) != 2:
|
||||
fails.append(f"cho 2 dong bi mo (Cowork, GraphRAG), thay {len(off)}")
|
||||
print(f"chua chon project : con {len(gated)} dong ({', '.join(nhan_gated)})")
|
||||
for lab in ("Cowork", "GraphRAG"):
|
||||
if lab in nhan_gated:
|
||||
fails.append(f"chua chon project ma {lab} van tren menu")
|
||||
if any(not on for _l, _p, _s, on in gated):
|
||||
fails.append("con dong bi mo tren menu — dang le phai bo han")
|
||||
|
||||
# --- rail header: project picker + new chat (Phase A) ------------------
|
||||
print()
|
||||
|
||||
@@ -123,9 +123,12 @@ 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:
|
||||
|
||||
+2
-4
@@ -78,8 +78,7 @@ _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": '<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": '<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"/>',
|
||||
"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"/>'
|
||||
@@ -199,8 +198,7 @@ _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": '<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
|
||||
"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
|
||||
}
|
||||
|
||||
|
||||
|
||||
+6
-83
@@ -13,20 +13,17 @@ chưa từng được gán nên gọi vào là AttributeError.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
|
||||
QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
|
||||
QMessageBox, QPushButton, QScrollArea, QSpinBox,
|
||||
QGroupBox, QHBoxLayout, QListWidget, QListWidgetItem,
|
||||
QScrollArea, QSpinBox,
|
||||
QTreeWidgetItem, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..i18n import tr
|
||||
from .dialog_buttons import dialog_buttons
|
||||
from .icons import IconLabel
|
||||
from .widgets import ToggleSwitch
|
||||
|
||||
|
||||
@@ -37,25 +34,6 @@ from ..presentation.settings.routing_settings_widget import RoutingSettingsWidge
|
||||
from ..presentation.settings.about_widget import AboutSettingsWidget
|
||||
|
||||
|
||||
def _sandbox_password_matches(entered: str, stored: str) -> bool:
|
||||
"""Whether ``entered`` unlocks the Sandbox Security group.
|
||||
|
||||
An empty ``stored`` must never match. ``DEFAULT_CONFIG`` ships
|
||||
``agent_security.sandbox_pw = ""`` and the config handed to this dialog is
|
||||
always deep-merged with those defaults, so a plain ``entered == stored``
|
||||
accepts an empty field on every install that never set a password. The MS365
|
||||
unlock guards the same way — see ``json_config_repository.unlock_ms365``.
|
||||
|
||||
Both sides are compared as UTF-8 bytes, not as ``str``:
|
||||
``compare_digest`` raises ``TypeError`` on ``str`` holding anything outside
|
||||
ASCII, and this app defaults to Vietnamese and ships to Japanese customers,
|
||||
so an accented password is ordinary input rather than an edge case.
|
||||
"""
|
||||
if not entered or not stored:
|
||||
return False
|
||||
return secrets.compare_digest(entered.encode("utf-8"), stored.encode("utf-8"))
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
"""Hộp thoại Cài đặt: cột mục lục bên trái, các trang bên phải
|
||||
(Nhà cung cấp · Connectors · Định tuyến · Tham số · Chung).
|
||||
@@ -108,29 +86,10 @@ class SettingsDialog(QDialog):
|
||||
self.sandbox_group = QGroupBox(tr("settings.group.sandbox"))
|
||||
sbl = QVBoxLayout(self.sandbox_group)
|
||||
|
||||
# --- Password protection for Sandbox Security (at top) ---
|
||||
self.sandbox_pw_label = IconLabel("lock", tr("settings.sandbox_pw_label"))
|
||||
sbl.addWidget(self.sandbox_pw_label)
|
||||
|
||||
pw_row = QHBoxLayout()
|
||||
self.sandbox_pw_edit = QLineEdit("")
|
||||
self.sandbox_pw_edit.setPlaceholderText(tr("settings.sandbox_pw_placeholder"))
|
||||
self.sandbox_pw_edit.setEchoMode(QLineEdit.Password)
|
||||
pw_row.addWidget(self.sandbox_pw_edit, 1)
|
||||
self.sandbox_unlock_btn = QPushButton(tr("settings.sandbox_unlock_btn"))
|
||||
self.sandbox_unlock_btn.clicked.connect(self._sandbox_unlock)
|
||||
pw_row.addWidget(self.sandbox_unlock_btn)
|
||||
self.sandbox_locked_status = IconLabel("lock", tr("settings.sandbox_locked"), color="#c00")
|
||||
self.sandbox_locked_status.text_label().setStyleSheet("color: #c00; font-weight: bold;")
|
||||
pw_row.addWidget(self.sandbox_locked_status)
|
||||
sbl.addLayout(pw_row)
|
||||
self._sandbox_unlocked = False # Start LOCKED — must enter password first
|
||||
self._sandbox_pw = sec.get("sandbox_pw", "")
|
||||
|
||||
# Separator line between pw section and sandbox settings
|
||||
pw_sep = QLabel("────────────────")
|
||||
sbl.addWidget(pw_sep)
|
||||
|
||||
# Nhóm này KHÔNG còn khoá bằng mật khẩu: bốn công tắc dưới đây bật/tắt
|
||||
# tự do. Khoá cũ chỉ là rào chống bấm nhầm ở phía giao diện, 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), nên bỏ đi
|
||||
# theo yêu cầu thay vì giữ một bước nhập mật khẩu không bảo vệ được gì.
|
||||
self.sandbox_confirm = ToggleSwitch(tr("settings.sandbox_confirm_commands"))
|
||||
self.sandbox_confirm.setChecked(bool(sec.get("cowork_confirm_commands", False)))
|
||||
self.sandbox_confirm.setToolTip(tr("settings.sandbox_confirm_commands_tooltip"))
|
||||
@@ -160,14 +119,6 @@ class SettingsDialog(QDialog):
|
||||
# Resource limits (CPU/Memory/Disk I/O) moved to the Parameter group
|
||||
# below — see _param_section("settings.group.sandbox_limits").
|
||||
|
||||
# Collect all sandbox-editable widgets and lock them until unlocked
|
||||
self._sandbox_widgets = [
|
||||
self.sandbox_confirm, self.sandbox_block_network,
|
||||
self.ai_check, self.sec_enabled,
|
||||
]
|
||||
for _w in self._sandbox_widgets:
|
||||
_w.setEnabled(False)
|
||||
|
||||
root.addWidget(self.sandbox_group)
|
||||
|
||||
# Connectors (MCP / REST API) are managed entirely in Monitoring → Tools
|
||||
@@ -299,34 +250,6 @@ class SettingsDialog(QDialog):
|
||||
|
||||
|
||||
|
||||
def _sandbox_unlock(self) -> None:
|
||||
"""Mở khoá nhóm cài đặt sandbox bằng mật khẩu.
|
||||
|
||||
Đây là khoá phía giao diện để chặn bấm nhầm vào một mục nhạy cảm, KHÔNG
|
||||
phải cơ chế bảo mật thật.
|
||||
"""
|
||||
pw = self.sandbox_pw_edit.text()
|
||||
if not self._sandbox_pw:
|
||||
# No password configured. Refusing with "wrong password" would be a
|
||||
# dead end — the user would keep retrying a password that cannot
|
||||
# exist — so name the actual state instead.
|
||||
QMessageBox.warning(self, tr("settings.sandbox_pw_unset_title"),
|
||||
tr("settings.sandbox_pw_unset_body"))
|
||||
return
|
||||
if _sandbox_password_matches(pw, self._sandbox_pw):
|
||||
self._sandbox_unlocked = True
|
||||
self.sandbox_locked_status.setText(tr("settings.sandbox_unlocked"))
|
||||
self.sandbox_locked_status.set_icon("unlock", "#090")
|
||||
self.sandbox_locked_status.text_label().setStyleSheet("color: #090; font-weight: bold;")
|
||||
# Enable all sandbox widgets
|
||||
for w in self._sandbox_widgets:
|
||||
w.setEnabled(True)
|
||||
QMessageBox.information(self, tr("settings.group.sandbox"),
|
||||
tr("settings.sandbox_unlocked_body"))
|
||||
else:
|
||||
QMessageBox.warning(self, tr("settings.sandbox_pw_wrong_title"),
|
||||
tr("settings.sandbox_pw_wrong_body"))
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Gom cấu hình từ mọi trang con rồi ghi xuống đĩa."""
|
||||
data = self.ctx.config.data
|
||||
|
||||
+72
-23
@@ -27,13 +27,14 @@ 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, QWidget):
|
||||
class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
|
||||
"""Trang chủ Workspace: cột project, cột lịch sử, và 5 sub-tab
|
||||
(Dự án · Cowork · Co4E · Thư mục · GraphRAG).
|
||||
|
||||
@@ -63,10 +64,9 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
|
||||
"""(label, index, icon_name, enabled) for EVERY sub-tab, hidden ones
|
||||
included.
|
||||
|
||||
The rail lists all five all the time and greys out the ones the project
|
||||
gate is currently closing (Cowork, GraphRAG) instead of removing them —
|
||||
same gate, shown rather than hidden, so the menu stops changing shape
|
||||
under the user's hand. See nav_subtabs() for the visible-only view.
|
||||
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",
|
||||
@@ -78,8 +78,8 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
|
||||
def subtab_available(self, index: int) -> bool:
|
||||
"""False while the project gate is holding this sub-tab shut.
|
||||
|
||||
The rail greys those rows out, but that only guards the rail. This lets
|
||||
every other route ask the same question of the same state.
|
||||
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))
|
||||
|
||||
@@ -203,27 +203,26 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
|
||||
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"))
|
||||
# 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"))
|
||||
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_tab_idx = self.tabs.addTab(self._folder_placeholder, tr("workspace.tab_folder"))
|
||||
|
||||
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._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.tabs.currentChanged.connect(self._on_tab_changed)
|
||||
|
||||
@@ -308,6 +307,7 @@ class WorkspaceTab(ProjectEditingMixin, 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,10 +421,50 @@ class WorkspaceTab(ProjectEditingMixin, 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:
|
||||
|
||||
@@ -610,7 +650,12 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
|
||||
counts = self._project_counts()
|
||||
self.project_list.blockSignals(True)
|
||||
self.project_list.clear()
|
||||
row_to_select = 0
|
||||
# -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
|
||||
@@ -897,6 +942,8 @@ class WorkspaceTab(ProjectEditingMixin, 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)
|
||||
@@ -940,6 +987,8 @@ class WorkspaceTab(ProjectEditingMixin, 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()
|
||||
|
||||
Reference in New Issue
Block a user