diff --git a/Task Tracking Template.xlsx b/Task Tracking Template.xlsx new file mode 100644 index 0000000..2456d3e Binary files /dev/null and b/Task Tracking Template.xlsx differ diff --git a/application/conversations/core_runtime_adapter.py b/application/conversations/core_runtime_adapter.py index 0fea5ea..b527dcc 100644 --- a/application/conversations/core_runtime_adapter.py +++ b/application/conversations/core_runtime_adapter.py @@ -74,6 +74,14 @@ class CoreToolRuntime: đều phải tra tên, tra trên danh sách sẽ chậm dần theo số tool. """ self._output_dir = Path(output_dir) + # Every sandboxed tool (run_command included) gets this as its cwd — + # it must exist BEFORE the first tool call, same as the older + # run_cowork() (core/chat_agent.py) already does at its output_dir. + # Without this, a per-turn ".turns/" folder that was never created + # makes run_command's subprocess.Popen(cwd=...) fail immediately with + # WinError 267 ("directory name is invalid") before the command even + # starts — no network, no output, just an opaque OS error. + self._output_dir.mkdir(parents=True, exist_ok=True) self._title = title self._extra_tools = list(extra_tools or ()) self._extra_names = {getattr(t, "name", "") for t in self._extra_tools} diff --git a/core/cloud_workspace_sync.py b/core/cloud_workspace_sync.py new file mode 100644 index 0000000..51a815b --- /dev/null +++ b/core/cloud_workspace_sync.py @@ -0,0 +1,114 @@ +"""Mirror a OneDrive/SharePoint folder to/from a local directory (DF-007). + +This is deliberately NOT a general sync engine: every existing tool +(``run_command``, ``read_file``, ``write_file``...) operates on a real local +``Path`` (``Project.output_dir`` — see ``core/projects.py::Project.workspace_dir``), +and that contract does not change here. A cloud-backed project's +``output_dir`` still points at a real local folder; this module only knows how +to pull that folder's content down from Graph once, and push it back up once, +both on explicit user action (a button click) — there is no background +watcher, no continuous sync, no delete propagation, and no conflict +resolution beyond "whichever side ran last wins" for a given file. See the +DF-007 plan for why: OneDrive/SharePoint sync-client detection is unreliable, +so a local mirror + manual sync is the only predictable option that does not +touch the sandboxed command/file tools. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List + +from . import ms365_graph as graph + + +@dataclass +class SyncReport: + """Kết quả một lượt tải xuống/đẩy lên — hiển thị cho người dùng sau khi chạy.""" + transferred: int = 0 + skipped_too_large: List[str] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + + +def _list_children(token: str, cloud_source: Dict[str, str], remote_path: str) -> List[dict]: + provider = cloud_source.get("provider") + if provider == "sharepoint": + return graph.list_sharepoint_files(token, cloud_source["site_id"], remote_path) + return graph.list_onedrive_files(token, remote_path) + + +def _download_file(token: str, cloud_source: Dict[str, str], remote_path: str) -> bytes: + if cloud_source.get("provider") == "sharepoint": + return graph.download_sharepoint_file_bytes(token, cloud_source["site_id"], remote_path) + return graph.download_onedrive_file_bytes(token, remote_path) + + +def _upload_file(token: str, cloud_source: Dict[str, str], remote_path: str, data: bytes) -> None: + if cloud_source.get("provider") == "sharepoint": + graph.upload_sharepoint_file_bytes(token, cloud_source["site_id"], remote_path, data) + else: + graph.upload_onedrive_file_bytes(token, remote_path, data) + + +def download_folder(token: str, cloud_source: Dict[str, str], local_dir: Path) -> SyncReport: + """Tải toàn bộ cây thư mục ``cloud_source['remote_path']`` xuống ``local_dir``, + giữ nguyên cấu trúc thư mục con. Ghi đè file local nếu đã tồn tại (một + chiều: cloud thắng). Không xoá file local nào không còn ở phía cloud.""" + report = SyncReport() + root_remote = cloud_source.get("remote_path", "") + local_dir.mkdir(parents=True, exist_ok=True) + + def _walk(remote_path: str, local_sub: Path) -> None: + try: + children = _list_children(token, cloud_source, remote_path) + except graph.Ms365GraphError as exc: + report.errors.append(f"{remote_path or '/'}: {exc}") + return + for item in children: + name = item.get("name", "") + if not name: + continue + child_remote = f"{remote_path}/{name}" if remote_path else name + child_local = local_sub / name + if "folder" in item: + child_local.mkdir(parents=True, exist_ok=True) + _walk(child_remote, child_local) + else: + try: + data = _download_file(token, cloud_source, child_remote) + child_local.write_bytes(data) + report.transferred += 1 + except graph.Ms365GraphError as exc: + report.errors.append(f"{child_remote}: {exc}") + + _walk(root_remote, local_dir) + return report + + +def upload_folder(token: str, cloud_source: Dict[str, str], local_dir: Path) -> SyncReport: + """Đẩy mọi file dưới ``local_dir`` lên đúng đường dẫn tương ứng phía cloud + (tạo mới hoặc ghi đè). Một chiều: local thắng cho từng file được duyệt qua. + Không xoá file cloud nào đã bị xoá ở local, không phát hiện xung đột.""" + report = SyncReport() + root_remote = cloud_source.get("remote_path", "") + local_dir = Path(local_dir) + for dirpath, _dirnames, filenames in os.walk(local_dir): + rel_dir = Path(dirpath).relative_to(local_dir) + for fname in filenames: + local_file = Path(dirpath) / fname + rel_parts = [] if str(rel_dir) == "." else list(rel_dir.parts) + rel_parts.append(fname) + child_remote = "/".join(([root_remote] if root_remote else []) + rel_parts) + try: + data = local_file.read_bytes() + _upload_file(token, cloud_source, child_remote, data) + report.transferred += 1 + except graph.Ms365GraphError as exc: + if "too large" in str(exc): + report.skipped_too_large.append(child_remote) + else: + report.errors.append(f"{child_remote}: {exc}") + except OSError as exc: + report.errors.append(f"{child_remote}: {exc}") + return report diff --git a/core/ms365_graph.py b/core/ms365_graph.py index 2b1fb1f..402ae16 100644 --- a/core/ms365_graph.py +++ b/core/ms365_graph.py @@ -196,6 +196,40 @@ def write_onedrive_file(token: str, path: str, content: str) -> dict: return resp.json() +# Graph's "simple upload" (a single PUT to .../content) is documented to only +# support items up to 4 MiB; anything larger needs a chunked "upload session" +# (createUploadSession + PUT-per-range), which this module does not implement +# (see DF-007 cloud workspace picker — v1 explicitly skips large files rather +# than silently truncating or corrupting them). +MAX_SIMPLE_UPLOAD_BYTES = 4 * 1024 * 1024 + + +def _check_upload_size(data: bytes) -> None: + if len(data) > MAX_SIMPLE_UPLOAD_BYTES: + raise Ms365GraphError( + f"File too large for simple upload ({len(data)} bytes > " + f"{MAX_SIMPLE_UPLOAD_BYTES} bytes) — chunked upload sessions are not " + "implemented yet." + ) + + +def download_onedrive_file_bytes(token: str, path: str) -> bytes: + """Đọc RAW BYTES một tệp OneDrive (không ép UTF-8/không cắt) — dùng cho + mirror thư mục cloud xuống local, khác với :func:`read_onedrive_file` vốn + chỉ dành cho việc đọc nội dung văn bản vào ngữ cảnh chat.""" + resp = _request("GET", f"/me/drive/root:/{_path_segment(path)}:/content", token) + return resp.content + + +def upload_onedrive_file_bytes(token: str, path: str, data: bytes) -> dict: + """Ghi RAW BYTES vào một tệp OneDrive (tạo mới hoặc ghi đè). Xem + :data:`MAX_SIMPLE_UPLOAD_BYTES`.""" + _check_upload_size(data) + resp = _request("PUT", f"/me/drive/root:/{_path_segment(path)}:/content", token, + data=data, headers={"Content-Type": "application/octet-stream"}) + return resp.json() + + def _encode_share_url(url: str) -> str: """Encode a OneDrive/SharePoint sharing URL into Graph's ``u!`` share-id form (see Microsoft's 'Get access to shared items' docs).""" @@ -229,6 +263,24 @@ def list_sharepoint_files(token: str, site_id: str, path: str = "") -> List[dict return resp.json().get("value", []) +def download_sharepoint_file_bytes(token: str, site_id: str, path: str) -> bytes: + """Đọc RAW BYTES một tệp trong thư viện tài liệu SharePoint — xem + :func:`download_onedrive_file_bytes`.""" + resp = _request( + "GET", f"/sites/{quote(site_id)}/drive/root:/{_path_segment(path)}:/content", token) + return resp.content + + +def upload_sharepoint_file_bytes(token: str, site_id: str, path: str, data: bytes) -> dict: + """Ghi RAW BYTES vào một tệp trong thư viện tài liệu SharePoint. Xem + :data:`MAX_SIMPLE_UPLOAD_BYTES`.""" + _check_upload_size(data) + resp = _request( + "PUT", f"/sites/{quote(site_id)}/drive/root:/{_path_segment(path)}:/content", token, + data=data, headers={"Content-Type": "application/octet-stream"}) + return resp.json() + + # ---- Teams meeting transcripts ------------------------------------------ def find_online_meeting(token: str, join_url: str) -> List[dict]: """Tìm cuộc họp online theo link tham gia.""" diff --git a/core/projects.py b/core/projects.py index 1cb11ac..f0f2ed4 100644 --- a/core/projects.py +++ b/core/projects.py @@ -65,6 +65,13 @@ class Project: # auto_run: None → follow the global agent_security.cowork_confirm_commands; # True → auto-approve commands (no confirm); False → always confirm. auto_run: Optional[bool] = None + # {} = an ordinary local/managed workspace. Non-empty when ``output_dir`` + # is a LOCAL MIRROR of a OneDrive/SharePoint folder (see + # core/cloud_workspace_sync.py) — {"provider": "onedrive"|"sharepoint", + # "site_id": "", "site_name": "", "remote_path": ""}. ``output_dir`` itself + # always stays a real local path; nothing that reads ``workspace_dir()`` + # needs to change because of this field. + cloud_source: Dict[str, str] = field(default_factory=dict) def workspace_dir(self, base: Path = None) -> Path: """The project's sandbox root. Every chat of the project writes inside diff --git a/i18n/__init__.py b/i18n/__init__.py index 20f004a..ca48d90 100644 --- a/i18n/__init__.py +++ b/i18n/__init__.py @@ -37,6 +37,7 @@ from . import skills_dialog as _skills_dialog from . import libreoffice_view as _libreoffice_view from . import agents_admin_tab as _agents_admin_tab from . import monitoring_overview as _monitoring_overview +from . import cloud_workspace as _cloud_workspace # Gộp theo đúng thứ tự cũ: khoá trùng thì cụm sau thắng, y như khi tất cả # còn nằm chung một dict literal. @@ -51,6 +52,7 @@ STRINGS: Dict[str, Dict[str, str]] = { **_libreoffice_view.STRINGS, **_agents_admin_tab.STRINGS, **_monitoring_overview.STRINGS, + **_cloud_workspace.STRINGS, } diff --git a/i18n/agents_admin_tab.py b/i18n/agents_admin_tab.py index 7f92e5c..26083b3 100644 --- a/i18n/agents_admin_tab.py +++ b/i18n/agents_admin_tab.py @@ -232,6 +232,8 @@ STRINGS: Dict[str, Dict[str, str]] = { "ja": "行をフィルター(質問を入力しても可)…", "vi": "Lọc dòng (hoặc gõ câu hỏi rồi bấm )…"}, "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.pricing_title": { "en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)", "vi": "Bảng giá model (USD / 1 triệu token)"}, diff --git a/i18n/cloud_workspace.py b/i18n/cloud_workspace.py new file mode 100644 index 0000000..f590fee --- /dev/null +++ b/i18n/cloud_workspace.py @@ -0,0 +1,116 @@ +"""DF-007 — Microsoft 365 sign-in dialog + cloud (OneDrive/SharePoint) +folder picker. Deliberately its own module rather than reusing the +similarly-named orphaned keys under ``settings.ms365_*`` in ``cowork_tab.py``/ +``settings_dialog.py`` — those are leftovers from a MS365 sign-in UI that was +removed (see ``ui/settings_dialog.py`` module docstring) and the two files +disagree with each other on wording for several duplicate keys, so reusing +them risked resurrecting an inconsistency rather than a clean, tested string +set.""" +from __future__ import annotations + +STRINGS = { + # ---- ui/ms365_signin_dialog.py ---- + "ms365_signin.title": { + "en": "Sign in to Microsoft 365", "ja": "Microsoft 365 にサインイン", + "vi": "Đăng nhập Microsoft 365", + }, + "ms365_signin.already": { + "en": "Signed in as {who}.", "ja": "{who} としてサインイン済みです。", + "vi": "Đã đăng nhập với {who}.", + }, + "ms365_signin.intro": { + "en": "Sign in with your Microsoft work/school (or personal) account to " + "browse OneDrive/SharePoint folders.", + "ja": "OneDrive/SharePoint のフォルダーを参照するには、Microsoft の職場/学校\n" + "(または個人) アカウントでサインインしてください。", + "vi": "Đăng nhập bằng tài khoản Microsoft (công ty/trường học hoặc cá nhân) " + "để duyệt thư mục OneDrive/SharePoint.", + }, + "ms365_signin.button": { + "en": "Sign in", "ja": "サインイン", "vi": "Đăng nhập", + }, + "ms365_signin.signing_in": { + "en": "Signing in…", "ja": "サインイン中…", "vi": "Đang đăng nhập…", + }, + "ms365_signin.code_hint": { + "en": "Open {url} and enter this code:", "ja": "{url} を開いてこのコードを入力してください:", + "vi": "Mở {url} và nhập mã sau:", + }, + "ms365_signin.open_link": { + "en": "Open link", "ja": "リンクを開く", "vi": "Mở link", + }, + "ms365_signin.failed": { + "en": "Sign-in failed: {err}", "ja": "サインインに失敗しました: {err}", + "vi": "Đăng nhập thất bại: {err}", + }, + "ms365_signin.cancel": { + "en": "Cancel", "ja": "キャンセル", "vi": "Hủy", + }, + # ---- ui/cloud_folder_picker_dialog.py ---- + "cloud_picker.title": { + "en": "Choose a OneDrive/SharePoint folder", "ja": "OneDrive/SharePoint フォルダーを選択", + "vi": "Chọn thư mục OneDrive/SharePoint", + }, + "cloud_picker.source_onedrive": { + "en": "My OneDrive", "ja": "自分の OneDrive", "vi": "OneDrive của tôi", + }, + "cloud_picker.source_sharepoint": { + "en": "SharePoint site", "ja": "SharePoint サイト", "vi": "Site SharePoint", + }, + "cloud_picker.search_sites_placeholder": { + "en": "Search SharePoint sites…", "ja": "SharePoint サイトを検索…", + "vi": "Tìm site SharePoint…", + }, + "cloud_picker.search_btn": { + "en": "Search", "ja": "検索", "vi": "Tìm", + }, + "cloud_picker.up": { + "en": ".. (up)", "ja": ".. (上へ)", "vi": ".. (lùi lại)", + }, + "cloud_picker.choose_here": { + "en": "Choose this folder", "ja": "このフォルダーを選択", "vi": "Chọn thư mục này", + }, + "cloud_picker.cancel": { + "en": "Cancel", "ja": "キャンセル", "vi": "Hủy", + }, + "cloud_picker.load_failed": { + "en": "Could not load this folder: {err}", "ja": "フォルダーを読み込めませんでした: {err}", + "vi": "Không tải được thư mục này: {err}", + }, + "cloud_picker.no_sites": { + "en": "No matching SharePoint sites.", "ja": "一致する SharePoint サイトがありません。", + "vi": "Không tìm thấy site SharePoint phù hợp.", + }, + # ---- ui/workspace_tab.py additions ---- + "workspace.cloud_pick": { + "en": "Choose from OneDrive/SharePoint…", "ja": "OneDrive/SharePoint から選択…", + "vi": "Chọn từ OneDrive/SharePoint…", + }, + "workspace.cloud_sync": { + "en": "Sync with cloud", "ja": "クラウドと同期", "vi": "Đồng bộ với cloud", + }, + "workspace.cloud_badge_onedrive": { + "en": "☁ Local mirror of OneDrive: {path}", "ja": "☁ OneDrive のローカルミラー: {path}", + "vi": "☁ Bản sao cục bộ của OneDrive: {path}", + }, + "workspace.cloud_badge_sharepoint": { + "en": "☁ Local mirror of SharePoint ({site}): {path}", + "ja": "☁ SharePoint ({site}) のローカルミラー: {path}", + "vi": "☁ Bản sao cục bộ của SharePoint ({site}): {path}", + }, + "workspace.cloud_sync_result": { + "en": "Sync done — {up} uploaded, {down} downloaded.", + "ja": "同期完了 — アップロード {up} 件、ダウンロード {down} 件。", + "vi": "Đồng bộ xong — {up} tệp đẩy lên, {down} tệp tải về.", + }, + "workspace.cloud_sync_errors": { + "en": "{n} item(s) had errors — see details below.", + "ja": "{n} 件のエラーがありました — 詳細は下記のとおりです。", + "vi": "{n} mục bị lỗi — chi tiết bên dưới.", + }, + "workspace.cloud_sync_skipped": { + "en": "{n} file(s) skipped (over 4 MB, not supported yet).", + "ja": "{n} 件のファイルはスキップされました (4 MB 超、未対応)。", + "vi": "{n} tệp bị bỏ qua (quá 4 MB, chưa hỗ trợ).", + }, +} diff --git a/infrastructure/filesystem/command_tools.py b/infrastructure/filesystem/command_tools.py index c5e13c4..37aa4c4 100644 --- a/infrastructure/filesystem/command_tools.py +++ b/infrastructure/filesystem/command_tools.py @@ -62,7 +62,9 @@ def run_command(ctx: ToolContext, args: Dict[str, Any], """ from cowork_local.core.deps import network_blocked_env, run_cancellable, sandbox_env from cowork_local.core.sandbox_manager import ExecutionConfig, SandboxManager - from cowork_local.security.command_risk_classifier import classify_command + from cowork_local.security.command_risk_classifier import ( + classify_command, command_bypasses_network_proxy, + ) command = str(args.get("command", "")).strip() if not command: @@ -74,6 +76,21 @@ def run_command(ctx: ToolContext, args: Dict[str, Any], denial = "Command blocked by security policy: " + "; ".join(risk.reasons) return {"ok": False, "output": denial} + # Every sandbox backend's network block is a proxy-env-var trick (see + # core/deps.py::network_blocked_env) — it does nothing against a tool + # that reaches the network without an HTTP proxy (ping/ICMP, nslookup/ + # direct DNS, ssh/ftp/raw TCP...). Deny those BY NAME here instead, so + # "Chặn mạng cho lệnh do agent chạy" actually blocks them too. + if ctx.block_network: + bypass_tool = command_bypasses_network_proxy(command) + if bypass_tool: + return {"ok": False, "output": ( + f"Command blocked: '{bypass_tool}' can reach the network without going through " + "an HTTP proxy, so the sandbox's network block (which only filters proxy-aware " + "traffic) cannot stop it by itself — blocked by name instead while " + "'Chặn mạng cho lệnh do agent chạy' is on." + )} + # Route through SandboxManager for risk-based isolation mgr = SandboxManager(ExecutionConfig( enabled=True, diff --git a/presentation/co4e/canvas_items.py b/presentation/co4e/canvas_items.py index 079f441..8c3ab07 100644 --- a/presentation/co4e/canvas_items.py +++ b/presentation/co4e/canvas_items.py @@ -199,6 +199,14 @@ class _NodeItem(QGraphicsObject): e.accept() return super().mousePressEvent(e) + if self.isSelected(): + # itemChange() only emits node_selected when the SELECTION STATE + # actually flips (ItemSelectedHasChanged) — clicking a node that + # was already selected (e.g. left selected when a run started) + # never re-fires it, so the property panel silently kept showing + # stale data and looked "locked" while the node ran. Emit + # explicitly on every click so the panel always reloads. + self.canvas.node_selected.emit(self.node.id) def mouseMoveEvent(self, e): """Rê chuột trong lúc kéo nối: vẽ lại đường nét đứt theo con trỏ.""" diff --git a/presentation/co4e/co4e_canvas_widget.py b/presentation/co4e/co4e_canvas_widget.py index 2d0c896..dfd9262 100644 --- a/presentation/co4e/co4e_canvas_widget.py +++ b/presentation/co4e/co4e_canvas_widget.py @@ -273,6 +273,15 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView): item.status = status item.update() + def node_status(self, node_id: str) -> str: + """Trạng thái chạy hiện tại của một node — "idle" nếu không tìm thấy. + + Dùng để quyết định có khóa bảng thuộc tính bên phải hay không khi + người dùng chọn node (xem ``StepConfigPanel.set_locked``). + """ + item = self._nodes.get(node_id) + return item.status if item is not None else "idle" + def reset_statuses(self) -> None: """Đưa mọi node về trạng thái chờ — gọi trước mỗi lần chạy lại luồng.""" for it in self._nodes.values(): diff --git a/presentation/co4e/co4e_flow_tabs.py b/presentation/co4e/co4e_flow_tabs.py index ffe70c9..d0944ad 100644 --- a/presentation/co4e/co4e_flow_tabs.py +++ b/presentation/co4e/co4e_flow_tabs.py @@ -101,6 +101,11 @@ class Co4EFlowTabsMixin: self.center_stack.setCurrentIndex(1) self._sync_runs_toggle(False) self._apply_workflow(self._flows[flow_idx]) + # _apply_workflow() rebuilds the canvas from wf.nodes/edges, which + # resets every node's live status to "idle" — without this, coming + # back to a flow that's still running (e.g. from the Runs page) + # shows every node as idle even though it's actually mid-run. + self._reflect_active_run(self._flows[flow_idx].id) def _sync_runs_toggle(self, on: bool) -> None: """Keep the Runs toggle showing which page is up, however it got there (a double-click in the runs table also switches pages).""" diff --git a/presentation/co4e/co4e_runs.py b/presentation/co4e/co4e_runs.py index f16b6d6..912af55 100644 --- a/presentation/co4e/co4e_runs.py +++ b/presentation/co4e/co4e_runs.py @@ -17,6 +17,7 @@ from PySide6.QtWidgets import QInputDialog, QMenu, QMessageBox, QTableWidget, QT from ...core import co4e from ...i18n import tr from ...theme import current_palette +from .co4e_workflow_crud import _LOCKED_NODE_STATUSES class Co4ERunsMixin: @@ -155,7 +156,14 @@ class Co4ERunsMixin: t = ev.get("type") if t == "node_status": if shown: - self.canvas.update_node_status(ev.get("node_id"), ev.get("status")) + nid = ev.get("node_id") + self.canvas.update_node_status(nid, ev.get("status")) + # If the panel is showing THIS node right now (e.g. it was + # idle and the user had it open when the run started), keep + # the lock in sync instead of waiting for the next click. + if nid == getattr(self.config, "_node_id", None): + self.config.set_locked( + self.canvas.node_status(nid) in _LOCKED_NODE_STATUSES) elif t == "node_output": if run_wf is not None: self._outputs_for(run_wf)[ev["node_id"]] = ev.get("output", "") diff --git a/presentation/co4e/co4e_workflow_crud.py b/presentation/co4e/co4e_workflow_crud.py index aabe2de..4cb0d29 100644 --- a/presentation/co4e/co4e_workflow_crud.py +++ b/presentation/co4e/co4e_workflow_crud.py @@ -10,10 +10,13 @@ from typing import List, Optional from PySide6.QtCore import QSize, Qt from PySide6.QtWidgets import QInputDialog, QMenu from ...core import co4e +from ...core.co4e import STEP_DONE, STEP_RUNNING from ...i18n import tr from ...ui.icons import icon from ...presentation.co4e.co4e_chat_view import _skill_names +_LOCKED_NODE_STATUSES = (STEP_RUNNING, STEP_DONE) + class Co4EWorkflowCrudMixin: """Phần tạo/mở/lưu/xoá luồng của Co4E Studio. @@ -163,10 +166,15 @@ class Co4EWorkflowCrudMixin: self.canvas.add_palette_step(co4e.Step(label="New Step"), self.canvas.mapToScene(self.canvas.rect().center())) 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.""" + """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ả. + """ for n in self.canvas.nodes(): if n.id == node_id: self.config.load_step(node_id, n.data, _skill_names()) + self.config.set_locked(self.canvas.node_status(node_id) in _LOCKED_NODE_STATUSES) if self._config_collapsed: self._toggle_config() return diff --git a/presentation/co4e/node_property_panel.py b/presentation/co4e/node_property_panel.py index 7e256a4..3b12eba 100644 --- a/presentation/co4e/node_property_panel.py +++ b/presentation/co4e/node_property_panel.py @@ -38,7 +38,7 @@ from PySide6.QtWidgets import ( ) from ...config import PROVIDER_LABELS -from ...core.co4e import PERMISSION_PRESETS, Step +from ...core.co4e import PERMISSION_PRESETS, STEP_DONE, STEP_RUNNING, Step from ...i18n import tr from ...ui.icons import icon, icon_picker_combo from .node_property_actions_mixin import _StepConfigActionsMixin @@ -65,6 +65,8 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea): self._step: Optional[Step] = None self._node_id = "" self._loading = False + self._ctx_available = ctx is not None + self._locked = False self.setWidgetResizable(True) host = QWidget() self.setWidget(host) @@ -281,6 +283,27 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea): self.sub_list.addItem(sub.agent) self._loading = False + 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. + """ + self._locked = locked + editable = not locked + for w in (self.label_edit, self.role_edit, self.icon_edit, + self.instructions_edit, self.context_edit, + self.model_combo, self.perm_combo, self.verify_chk, + self.rounds_spin, self.skills_list, + self.attach_add_btn, self.attach_del_btn, + self.sub_add_btn, self.sub_del_btn, self.sub_list): + w.setEnabled(editable) + self.gen_btn.setEnabled(editable and self._ctx_available) + self.load_models_btn.setEnabled(editable and self._ctx_available) + def clear_step(self) -> None: """Xoá bảng khi không có bước nào được chọn.""" self._step = None diff --git a/presentation/folder/ai_file_editor_dialog.py b/presentation/folder/ai_file_editor_dialog.py index af0e02f..33923ef 100644 --- a/presentation/folder/ai_file_editor_dialog.py +++ b/presentation/folder/ai_file_editor_dialog.py @@ -21,9 +21,9 @@ from __future__ import annotations from typing import List, Optional from PySide6.QtWidgets import ( - QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget, + QComboBox, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget, ) -from PySide6.QtCore import Signal +from PySide6.QtCore import Qt, Signal from cowork_local.i18n import on_language_changed, tr from cowork_local.presentation.folder.ai_edit_model_resolver import AiEditModelResolver @@ -32,6 +32,45 @@ from cowork_local.theme import current_palette from cowork_local.ui.chat_view import ChatView +class _AutoExpandInput(QPlainTextEdit): + """Instruction box: grows with content (1..~6 lines, then scrolls), Enter + submits, Shift+Enter inserts a newline — same convention as the Cowork + composer (``presentation/chat/chat_input_box.py::_Input``), minus its + ``/skill``/``/agent`` popups and drag-drop attachment handling, which + don't apply to a single AI-edit instruction. DF-008: a fixed-height + single-line ``QLineEdit`` read as cramped for a full instruction; this + replaces it instead of just nudging the height up further.""" + + submit = Signal() + + MIN_HEIGHT = 36 # matches the old QLineEdit's bumped-up height + MAX_HEIGHT = 140 # ~6 lines, then it scrolls instead of growing further + + def __init__(self, parent=None): + super().__init__(parent) + self.setTabChangesFocus(True) # Tab moves focus, doesn't insert a tab + self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.textChanged.connect(self._adjust_height) + self._adjust_height() + + def _adjust_height(self) -> None: + # QPlainTextEdit reports the document height in LINES, not pixels — + # convert via line spacing (same approach as chat_input_box.py). + lines = self.document().size().height() or 1 + line_px = self.fontMetrics().lineSpacing() + h = int(lines * line_px + 2 * self.frameWidth() + 12) + h = max(self.MIN_HEIGHT, min(self.MAX_HEIGHT, h)) + if h != self.height(): + self.setFixedHeight(h) + + def keyPressEvent(self, e) -> None: # noqa: N802 + if e.key() in (Qt.Key_Return, Qt.Key_Enter) and not (e.modifiers() & Qt.ShiftModifier): + self.submit.emit() + return + super().keyPressEvent(e) + + class AiFileEditorDialog(QWidget): """Collapsible panel: a Cowork-style inline chat timeline, this panel's OWN model picker + routing toggle, an instruction box, and an Apply/ @@ -94,9 +133,9 @@ class AiFileEditorDialog(QWidget): ctx, self.ai_model_combo, self.ai_chat.add_status, self._confirm_routing_switch) row = QHBoxLayout() - self.ai_input = QLineEdit() + self.ai_input = _AutoExpandInput() self.ai_input.setPlaceholderText(tr("folder.ai_placeholder")) - self.ai_input.returnPressed.connect(self._ai_send) + self.ai_input.submit.connect(self._ai_send) row.addWidget(self.ai_input, 1) self.ai_send_btn = QPushButton(tr("folder.ai_send")) self.ai_send_btn.setObjectName("primary") @@ -170,7 +209,7 @@ class AiFileEditorDialog(QWidget): if not self.preview.root: self.ai_chat.add_error(tr("folder.ai_no_file")) return - instruction = self.ai_input.text().strip() + instruction = self.ai_input.toPlainText().strip() if not instruction: return self.ai_input.clear() diff --git a/presentation/monitoring/monitoring_tab.py b/presentation/monitoring/monitoring_tab.py index 59e8b4d..5302ed6 100644 --- a/presentation/monitoring/monitoring_tab.py +++ b/presentation/monitoring/monitoring_tab.py @@ -10,6 +10,7 @@ the ``status_message`` signal, ``select_subtab(index)``, ``nav_subtabs()``, """ from __future__ import annotations +from datetime import date, timedelta from typing import List from PySide6.QtCore import QTimer, Signal @@ -25,11 +26,24 @@ from .tabs.overview_tab import OverviewTab from .tabs.security_events_tab import SecurityEventsTab _REFRESH_MS = 3000 -# Comfortably larger than any realistic audit-log size — the event tables -# have never had pagination controls, so every tab still shows "all matching -# events" exactly like before; MonitoringQueryService's pagination support -# is exercised for real here, just not surfaced as UI (yet). +# Comfortably larger than any realistic audit-log size for the WINDOW of +# events _load_events() now actually reads (see _LOG_WINDOW_DAYS below) — this +# is MonitoringQueryService's query-side page size, kept unbounded so it +# always returns every matching event within the window; the user-facing +# "Số dòng/trang" control (DF-006 — see shared/event_table.py::set_page_size, +# shared/filter_scaffold.py::build_filter_scaffold's with_page_size) trims +# that down for DISPLAY, client-side, per event tab. _UNBOUNDED_PAGE_SIZE = 100_000 +# _load_events() re-reads the audit log from disk every _REFRESH_MS (3s) via +# _auto_refresh(), and audit_log.load_events()/load_shared_audit_events() are +# 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. +# 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 class MonitoringTab(QWidget): @@ -259,14 +273,20 @@ class MonitoringTab(QWidget): Có cấu hình thư mục chia sẻ VÀ đọc ra được dữ liệu thì dùng nó, để cả đội nhìn chung một bức tranh; rỗng thì rơi về nhật ký của máy này. + + Chỉ đọc ``_LOG_WINDOW_DAYS`` ngày gần nhất — cả hai nguồn đều lưu theo + file JSONL từng ngày, nên bounding ở đây tránh việc đọc lại TOÀN BỘ + lịch sử mỗi 3 giây (xem ``_auto_refresh``), là nguyên nhân thật của + DF-006 (gây nặng khi log lớn). """ + 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) + shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start) if shared_events: return shared_events - return audit_log.load_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 diff --git a/presentation/monitoring/shared/event_table.py b/presentation/monitoring/shared/event_table.py index 9a66299..147e18b 100644 --- a/presentation/monitoring/shared/event_table.py +++ b/presentation/monitoring/shared/event_table.py @@ -18,6 +18,7 @@ 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) class _TimeItem(QTableWidgetItem): @@ -70,6 +71,8 @@ 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._last_events: List[dict] = [] super().__init__(0, 7 if show_result else 6) self.setEditTriggers(QTableWidget.NoEditTriggers) self.setSelectionBehavior(QTableWidget.SelectRows) @@ -98,13 +101,25 @@ class EventTable(QTableWidget): cols += [tr("monitoring.col_detail_block") if not self._show_result else tr("monitoring.col_detail")] self.setHorizontalHeaderLabels(cols) + def page_size(self) -> int: + """Số dòng đang hiển thị mỗi trang.""" + return self._page_size + + 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).""" + self._page_size = n + self.set_events(self._last_events) + 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 ở ``_MAX_ROWS``. + """Đổ 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). 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. """ - events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:_MAX_ROWS] + self._last_events = events + events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:self._page_size] self.setSortingEnabled(False) self.setRowCount(len(events)) for row, ev in enumerate(events): diff --git a/presentation/monitoring/shared/filter_scaffold.py b/presentation/monitoring/shared/filter_scaffold.py index 8c02e06..625e60c 100644 --- a/presentation/monitoring/shared/filter_scaffold.py +++ b/presentation/monitoring/shared/filter_scaffold.py @@ -16,13 +16,13 @@ from typing import Callable, Dict, Optional from PySide6.QtCore import Qt from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtWidgets import ( - QApplication, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSplitter, - QTableWidget, QVBoxLayout, QWidget, + QApplication, QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, + QSplitter, QTableWidget, QVBoxLayout, QWidget, ) from ....i18n import tr from ....ui.icons import icon -from .event_table import ClickOutsideCloser, EventTable +from .event_table import PAGE_SIZE_OPTIONS, ClickOutsideCloser, EventTable from .event_detail_panel import EventDetailPanel @@ -47,11 +47,12 @@ def _sync_event_detail(table: EventTable, panel: EventDetailPanel) -> None: def build_filter_scaffold( page: QWidget, table: QTableWidget, *, on_refresh: Callable[[], None], title_key: Optional[str] = None, with_search: bool = True, - with_detail: bool = False, + with_detail: bool = False, with_page_size: bool = False, on_ai_filter: Optional[Callable[[QLineEdit, QPushButton], None]] = None, ) -> Dict[str, object]: """Dựng khung chung cho một tab sự kiện: tiêu đề, nút làm mới, ô tìm kiếm, - nút lọc bằng AI và panel chi tiết. + nút lọc bằng AI, control "Số dòng/trang" (nếu ``with_page_size``) và panel + chi tiết. Bốn tab sự kiện của màn Giám sát chỉ khác nhau ở nguồn dữ liệu, nên phần vỏ này được dựng một lần và dùng chung. @@ -88,6 +89,23 @@ def build_filter_scaffold( ai_btn.clicked.connect(lambda: on_ai_filter(search, ai_btn)) row.addWidget(search, 1) row.addWidget(ai_btn) + 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_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_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) lay.addLayout(row) parts.update(filter_edit=search, ai_filter_btn=ai_btn) diff --git a/presentation/monitoring/tabs/action_logs_tab.py b/presentation/monitoring/tabs/action_logs_tab.py index 60c2b27..fd52b11 100644 --- a/presentation/monitoring/tabs/action_logs_tab.py +++ b/presentation/monitoring/tabs/action_logs_tab.py @@ -25,13 +25,15 @@ class ActionLogsTab(QWidget): parts = build_filter_scaffold( self, self.table, on_refresh=on_refresh_all, title_key="monitoring.action_logs_title", - with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter) + with_search=True, with_detail=True, with_page_size=True, + on_ai_filter=self._start_ai_filter) self.title_lbl = parts["title_lbl"] self.title_key = parts["title_key"] self.title_refresh_btn = parts["title_refresh_btn"] self.filter_edit = parts["filter_edit"] self.ai_filter_btn = parts["ai_filter_btn"] self.detail_panel = parts["detail_panel"] + self.page_size_label = parts["page_size_label"] def set_events(self, events: List[dict]) -> None: """Đổ danh sách sự kiện vào bảng.""" @@ -44,6 +46,7 @@ class ActionLogsTab(QWidget): self.detail_panel.retranslate() 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")) 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.""" diff --git a/presentation/monitoring/tabs/mcp_tab.py b/presentation/monitoring/tabs/mcp_tab.py index b355ecb..5038da2 100644 --- a/presentation/monitoring/tabs/mcp_tab.py +++ b/presentation/monitoring/tabs/mcp_tab.py @@ -25,13 +25,15 @@ class McpTab(QWidget): parts = build_filter_scaffold( self, self.table, on_refresh=on_refresh_all, title_key="monitoring.mcp_history_title", - with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter) + with_search=True, with_detail=True, with_page_size=True, + on_ai_filter=self._start_ai_filter) self.title_lbl = parts["title_lbl"] self.title_key = parts["title_key"] self.title_refresh_btn = parts["title_refresh_btn"] self.filter_edit = parts["filter_edit"] self.ai_filter_btn = parts["ai_filter_btn"] self.detail_panel = parts["detail_panel"] + self.page_size_label = parts["page_size_label"] def set_events(self, events: List[dict]) -> None: """Đổ danh sách sự kiện vào bảng.""" @@ -44,6 +46,7 @@ class McpTab(QWidget): self.detail_panel.retranslate() 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")) 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.""" diff --git a/presentation/monitoring/tabs/security_events_tab.py b/presentation/monitoring/tabs/security_events_tab.py index 4283eca..4c961e7 100644 --- a/presentation/monitoring/tabs/security_events_tab.py +++ b/presentation/monitoring/tabs/security_events_tab.py @@ -31,13 +31,15 @@ class SecurityEventsTab(QWidget): parts = build_filter_scaffold( self, self.table, on_refresh=on_refresh_all, title_key="monitoring.security_events_title", - with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter) + with_search=True, with_detail=True, with_page_size=True, + on_ai_filter=self._start_ai_filter) self.title_lbl = parts["title_lbl"] self.title_key = parts["title_key"] self.title_refresh_btn = parts["title_refresh_btn"] self.filter_edit = parts["filter_edit"] self.ai_filter_btn = parts["ai_filter_btn"] self.detail_panel = parts["detail_panel"] + self.page_size_label = parts["page_size_label"] def set_events(self, events: List[dict]) -> None: """Đổ danh sách sự kiện vào bảng.""" @@ -50,6 +52,7 @@ class SecurityEventsTab(QWidget): self.detail_panel.retranslate() 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")) 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.""" diff --git a/presentation/shell/main_window.py b/presentation/shell/main_window.py index 6397379..d905a1a 100644 --- a/presentation/shell/main_window.py +++ b/presentation/shell/main_window.py @@ -212,6 +212,11 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin, self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) # Keep the floating Help assistant pinned to the bottom-right corner. if getattr(self, "help_agent", None) is not None: + # The Cowork composer can wrap an extra control row as the window + # narrows/widens, which changes how much bottom guard the dock + # needs — recompute it on every resize, not just reposition with + # whatever guard height was last measured at tab-entry time. + self._update_dock_guard() self.help_agent.reposition() def showEvent(self, event): # noqa: N802 - Qt override diff --git a/presentation/shell/nav_rail.py b/presentation/shell/nav_rail.py index 20104db..eb4b9a4 100644 --- a/presentation/shell/nav_rail.py +++ b/presentation/shell/nav_rail.py @@ -81,7 +81,7 @@ class NavRailMixin: # 16px icon up with the nav items' icons below (1px list frame + item # padding) — same indent level, same icon size as e.g. Dashboard. toggle_row = QHBoxLayout() - toggle_row.setContentsMargins(0, 8, 10, 8) + toggle_row.setContentsMargins(10, 8, 10, 8) toggle_row.addWidget(self._nav_toggle_btn, 0, Qt.AlignLeft) toggle_row.addStretch(1) nvl.addLayout(toggle_row) @@ -111,7 +111,7 @@ class NavRailMixin: self.nav_project_btn.menu().aboutToShow.connect(self._fill_rail_project_menu) self.nav_project_btn.setVisible(False) head = QVBoxLayout() - head.setContentsMargins(6, 0, 6, 6) + head.setContentsMargins(10, 0, 10, 6) head.setSpacing(6) head.addWidget(self.nav_project) head.addWidget(self.nav_project_btn) @@ -136,7 +136,7 @@ class NavRailMixin: self._nav_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) scroll_body = QWidget() sv = QVBoxLayout(scroll_body) - sv.setContentsMargins(0, 0, 0, 0) + sv.setContentsMargins(6, 0, 6, 0) sv.setSpacing(0) sv.addWidget(self.nav, 0) # RECENTS — the threads of the project named in the picker above, right diff --git a/presentation/shell/rail_metrics.py b/presentation/shell/rail_metrics.py index 5aa7d17..f1e478b 100644 --- a/presentation/shell/rail_metrics.py +++ b/presentation/shell/rail_metrics.py @@ -13,7 +13,7 @@ from PySide6.QtWidgets import QStyledItemDelegate # ---- kích thước --------------------------------------------------------- _NAV_EXPANDED_WIDTH = 150 _NAV_COLLAPSED_WIDTH = 54 -_NAV_ROW_INSET = 4 +_NAV_ROW_INSET = 8 _NAV_ROW_GAP = 6 _NAV_MIN_WIDTH = 132 _NAV_MAX_SHARE = 0.22 diff --git a/run.bat b/run.bat index 6c54853..e9785d6 100644 --- a/run.bat +++ b/run.bat @@ -100,10 +100,26 @@ if defined PYTHONPATH ( set "PYTHONIOENCODING=utf-8" cd /d "%REPO%" +rem -------------------------------------------------------------------------- +rem 4. An cua so console trong luc chay +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 -------------------------------------------------------------------------- +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%" if not "%RC%"=="0" ( + if exist "%CONSOLE_VIS%" ( + powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1 + ) echo. echo Ứng dụng thoát với mã lỗi %RC%. Xem thông báo ở trên. echo. diff --git a/scripts/console_visibility.ps1 b/scripts/console_visibility.ps1 new file mode 100644 index 0000000..ded9f7a --- /dev/null +++ b/scripts/console_visibility.ps1 @@ -0,0 +1,22 @@ +<# +.SYNOPSIS + Ẩn/hiện cửa sổ console hiện tại — dùng bởi run.bat để không hiện cửa sổ + cmd đen suốt phiên chạy app (app là GUI Qt, không cần console), nhưng vẫn + hiện lại được nếu app thoát lỗi để người dùng đọc thông báo. + +.PARAMETER Mode + 0 = ẩn (SW_HIDE), 5 = hiện lại (SW_SHOW). +#> +param( + [int]$Mode = 0 +) + +Add-Type -Name Win32 -Namespace CoworkLocalNative -MemberDefinition @" +[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); +[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); +"@ + +$hwnd = [CoworkLocalNative.Win32]::GetConsoleWindow() +if ($hwnd -ne [IntPtr]::Zero) { + [CoworkLocalNative.Win32]::ShowWindow($hwnd, $Mode) | Out-Null +} diff --git a/security/command_risk_classifier.py b/security/command_risk_classifier.py index 33eaf18..ee5c69f 100644 --- a/security/command_risk_classifier.py +++ b/security/command_risk_classifier.py @@ -73,6 +73,38 @@ _MODERATE_PATTERNS = [ r'\b(test|pytest|jest|mocha)\b', ] +# Tools that reach the network over ICMP/raw sockets/direct DNS instead of an +# HTTP(S) connection — none of them read HTTP_PROXY/HTTPS_PROXY, so +# core/deps.py::network_blocked_env()'s proxy-env-var block (the only network +# control this sandbox actually enforces) has no effect on them at all. Used +# by command_bypasses_network_proxy() to deny these BY NAME when the user has +# "Chặn mạng cho lệnh do agent chạy" on, since the proxy trick alone silently +# lets them through (see DF-005 in Defect Management). +_NETWORK_PROXY_BYPASS_PATTERNS = [ + r'\bping\b', r'\btracert\b', r'\btraceroute\b', r'\bnslookup\b', r'\bdig\b', + r'\btelnet\b', r'\bftp\b', r'\bsftp\b', r'\bscp\b', r'\bssh\b', + r'\bnc\b', r'\bncat\b', r'\bnetcat\b', r'\barp\b', + r'\btest-netconnection\b', r'\btest-connection\b', r'\bresolve-dnsname\b', +] + + +def command_bypasses_network_proxy(command: str) -> Optional[str]: + """Tên công cụ mạng đầu tiên khớp trong ``command`` mà không tôn trọng + HTTP_PROXY/HTTPS_PROXY — None nếu không có công cụ nào như vậy. + + ``network_blocked_env()`` chỉ set biến proxy, nên chỉ chặn được các công + cụ có ĐỌC biến đó (curl/pip/requests...). ``ping`` (ICMP), ``nslookup`` + (DNS trực tiếp), ``ssh``/``ftp`` (TCP thô)... đều đi qua giao thức khác, + biến proxy không có tác dụng gì với chúng — phải chặn riêng theo tên lệnh + khi ``block_network`` đang bật. + """ + cmd_lower = command.lower() + for pattern in _NETWORK_PROXY_BYPASS_PATTERNS: + m = re.search(pattern, cmd_lower, re.IGNORECASE) + if m: + return m.group() + return None + def classify_command(command: str, is_cowork_mode: bool = True) -> RiskResult: """Chấm điểm rủi ro một lệnh shell. diff --git a/tests/test_ai_file_editor_input.py b/tests/test_ai_file_editor_input.py new file mode 100644 index 0000000..decc98c --- /dev/null +++ b/tests/test_ai_file_editor_input.py @@ -0,0 +1,68 @@ +"""DF-008 — presentation/folder/ai_file_editor_dialog.py::_AutoExpandInput. + +The AI-edit instruction box was a fixed-height single-line QLineEdit (read as +cramped); it is now a QPlainTextEdit that grows with content, submits on +Enter, and inserts a newline on Shift+Enter — same convention as the Cowork +composer's input (presentation/chat/chat_input_box.py::_Input).""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication +from PySide6.QtCore import Qt +from PySide6.QtGui import QKeyEvent +from PySide6.QtCore import QEvent + +from cowork_local.presentation.folder.ai_file_editor_dialog import _AutoExpandInput + + +@pytest.fixture(scope="module") +def qapp(): + app = QApplication.instance() or QApplication([]) + yield app + + +def _press_enter(widget, shift: bool = False) -> None: + mods = Qt.ShiftModifier if shift else Qt.NoModifier + event = QKeyEvent(QEvent.KeyPress, Qt.Key_Return, mods) + widget.keyPressEvent(event) + + +def test_starts_at_min_height(qapp) -> None: + box = _AutoExpandInput() + assert box.height() == _AutoExpandInput.MIN_HEIGHT + + +def test_grows_with_multiline_content(qapp) -> None: + box = _AutoExpandInput() + start_height = box.height() + box.setPlainText("\n".join(f"line {i}" for i in range(10))) + assert box.height() > start_height + assert box.height() <= _AutoExpandInput.MAX_HEIGHT + + +def test_enter_emits_submit_and_does_not_insert_newline(qapp) -> None: + box = _AutoExpandInput() + box.setPlainText("hello") + received = [] + box.submit.connect(lambda: received.append(True)) + _press_enter(box) + assert received == [True] + assert box.toPlainText() == "hello" # Enter did not add a newline + + +def test_shift_enter_inserts_newline_without_submitting(qapp) -> None: + box = _AutoExpandInput() + box.setPlainText("hello") + cursor = box.textCursor() + cursor.movePosition(cursor.MoveOperation.End) + box.setTextCursor(cursor) + received = [] + box.submit.connect(lambda: received.append(True)) + _press_enter(box, shift=True) + assert received == [] + assert box.toPlainText() == "hello\n" diff --git a/tests/test_cloud_workspace_sync.py b/tests/test_cloud_workspace_sync.py new file mode 100644 index 0000000..e135faa --- /dev/null +++ b/tests/test_cloud_workspace_sync.py @@ -0,0 +1,132 @@ +"""DF-007 — core/cloud_workspace_sync.py: mirror a cloud folder to/from a +local directory. All Graph calls are faked (monkeypatch on the ``graph`` +module the sync module imports) — no network.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from cowork_local.core import cloud_workspace_sync as sync +from cowork_local.core import ms365_graph as graph + + +def _fake_tree(): + """root/ + a.txt + sub/ + b.txt + """ + files = {"a.txt": b"hello", "sub/b.txt": b"world"} + listing = { + "": [{"name": "a.txt"}, {"name": "sub", "folder": {}}], + "sub": [{"name": "b.txt"}], + } + return files, listing + + +def test_download_folder_mirrors_tree(tmp_path: Path, monkeypatch) -> None: + files, listing = _fake_tree() + + def fake_list_onedrive_files(token, path=""): + return listing.get(path, []) + + def fake_download_bytes(token, path): + return files[path] + + monkeypatch.setattr(graph, "list_onedrive_files", fake_list_onedrive_files) + monkeypatch.setattr(graph, "download_onedrive_file_bytes", fake_download_bytes) + + local_dir = tmp_path / "mirror" + report = sync.download_folder("tok", {"provider": "onedrive", "remote_path": ""}, local_dir) + + assert report.transferred == 2 + assert report.errors == [] + assert (local_dir / "a.txt").read_bytes() == b"hello" + assert (local_dir / "sub" / "b.txt").read_bytes() == b"world" + + +def test_download_folder_collects_errors_without_raising(tmp_path: Path, monkeypatch) -> None: + def fake_list_onedrive_files(token, path=""): + raise graph.Ms365GraphError("boom") + + monkeypatch.setattr(graph, "list_onedrive_files", fake_list_onedrive_files) + + local_dir = tmp_path / "mirror" + report = sync.download_folder("tok", {"provider": "onedrive", "remote_path": ""}, local_dir) + + assert report.transferred == 0 + assert len(report.errors) == 1 + assert "boom" in report.errors[0] + + +def test_upload_folder_pushes_every_file(tmp_path: Path, monkeypatch) -> None: + local_dir = tmp_path / "mirror" + (local_dir / "sub").mkdir(parents=True) + (local_dir / "a.txt").write_bytes(b"hello") + (local_dir / "sub" / "b.txt").write_bytes(b"world") + + uploaded = {} + + def fake_upload_bytes(token, path, data): + uploaded[path] = data + return {} + + monkeypatch.setattr(graph, "upload_onedrive_file_bytes", fake_upload_bytes) + + report = sync.upload_folder("tok", {"provider": "onedrive", "remote_path": "work"}, local_dir) + + assert report.transferred == 2 + assert uploaded == {"work/a.txt": b"hello", "work/sub/b.txt": b"world"} + + +def test_upload_folder_reports_files_over_the_simple_upload_limit(tmp_path: Path, monkeypatch) -> None: + local_dir = tmp_path / "mirror" + local_dir.mkdir() + (local_dir / "big.bin").write_bytes(b"x") + + def fake_upload_bytes(token, path, data): + raise graph.Ms365GraphError("File too large for simple upload (huge > 4 bytes)") + + monkeypatch.setattr(graph, "upload_onedrive_file_bytes", fake_upload_bytes) + + report = sync.upload_folder("tok", {"provider": "onedrive", "remote_path": ""}, local_dir) + + assert report.transferred == 0 + assert report.skipped_too_large == ["big.bin"] + assert report.errors == [] + + +def test_upload_size_guard_rejects_before_any_request(monkeypatch) -> None: + huge = b"x" * (graph.MAX_SIMPLE_UPLOAD_BYTES + 1) + + def fail_if_called(*a, **k): # pragma: no cover - must not be reached + raise AssertionError("_request should not be called for an oversized upload") + + monkeypatch.setattr(graph, "_request", fail_if_called) + + with pytest.raises(graph.Ms365GraphError, match="too large"): + graph.upload_onedrive_file_bytes("tok", "a.bin", huge) + + +def test_sharepoint_provider_uses_site_scoped_calls(tmp_path: Path, monkeypatch) -> None: + seen = {} + + def fake_list_sharepoint_files(token, site_id, path=""): + seen["list_site_id"] = site_id + return [{"name": "a.txt"}] if path == "" else [] + + def fake_download_sharepoint_bytes(token, site_id, path): + seen["download_site_id"] = site_id + return b"hi" + + monkeypatch.setattr(graph, "list_sharepoint_files", fake_list_sharepoint_files) + monkeypatch.setattr(graph, "download_sharepoint_file_bytes", fake_download_sharepoint_bytes) + + local_dir = tmp_path / "mirror" + cloud_source = {"provider": "sharepoint", "site_id": "site-123", "remote_path": ""} + report = sync.download_folder("tok", cloud_source, local_dir) + + assert report.transferred == 1 + assert seen["list_site_id"] == "site-123" + assert seen["download_site_id"] == "site-123" diff --git a/tests/test_monitoring_page_size.py b/tests/test_monitoring_page_size.py new file mode 100644 index 0000000..1df786a --- /dev/null +++ b/tests/test_monitoring_page_size.py @@ -0,0 +1,70 @@ +"""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).""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication +QWidget = pytest.importorskip("PySide6.QtWidgets").QWidget + +from cowork_local.presentation.monitoring.shared.event_table import ( + PAGE_SIZE_OPTIONS, EventTable, +) +from cowork_local.presentation.monitoring.shared.filter_scaffold import build_filter_scaffold + + +@pytest.fixture(scope="module") +def qapp(): + app = QApplication.instance() or QApplication([]) + yield app + + +def _events(n: int): + return [{"ts": f"2026-09-0{i % 9 + 1}T00:00:0{i % 9}", "kind": "tool_call", + "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_set_page_size_retrims_without_reloading(qapp) -> None: + table = EventTable() + table.set_events(_events(500)) + table.set_page_size(50) + assert table.page_size() == 50 + assert table.rowCount() == 50 + + +def test_page_size_combo_is_only_added_when_requested(qapp) -> None: + page = QWidget() + table = EventTable() + parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=False) + assert "page_size_combo" not in parts + + +def test_page_size_combo_changes_the_table(qapp) -> None: + page = QWidget() + table = EventTable() + table.set_events(_events(500)) + 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 + + idx = PAGE_SIZE_OPTIONS.index(50) + combo.setCurrentIndex(idx) + + assert table.page_size() == 50 + assert table.rowCount() == 50 diff --git a/tests/test_ms365_cloud_dialogs.py b/tests/test_ms365_cloud_dialogs.py new file mode 100644 index 0000000..272affb --- /dev/null +++ b/tests/test_ms365_cloud_dialogs.py @@ -0,0 +1,77 @@ +"""DF-007 — construction smoke tests for the two new MS365 cloud dialogs. +Not a full characterization suite (see tests/test_monitoring_tab_container.py +for the convention this follows) — just proves each dialog builds against a +real AppConfig/AppContext without touching the network (Graph calls faked via +monkeypatch).""" +from __future__ import annotations + +import copy +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication +QDialog = pytest.importorskip("PySide6.QtWidgets").QDialog + +from cowork_local.config import AppConfig, DEFAULT_CONFIG +from cowork_local.core import ms365_auth +from cowork_local.core import ms365_graph as graph +from cowork_local.ui.cloud_folder_picker_dialog import CloudFolderPickerDialog +from cowork_local.ui.ms365_signin_dialog import Ms365SignInDialog, ensure_signed_in + + +@pytest.fixture(scope="module") +def qapp(): + app = QApplication.instance() or QApplication([]) + yield app + + +@pytest.fixture() +def config(tmp_path): + return AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json") + + +def test_signin_dialog_constructs(qapp, config) -> None: + dialog = Ms365SignInDialog(config) + assert dialog.windowTitle() + + +def test_ensure_signed_in_short_circuits_when_already_signed_in(qapp, config, monkeypatch) -> None: + monkeypatch.setattr(ms365_auth, "is_signed_in", lambda cfg: True) + assert ensure_signed_in(None, config) is True + + +def test_cloud_folder_picker_constructs_and_lists_onedrive_root(qapp, config, monkeypatch) -> None: + monkeypatch.setattr(ms365_auth, "get_access_token", lambda tenant_id, client_id: "fake-token") + monkeypatch.setattr(graph, "list_onedrive_files", lambda token, path="": [ + {"name": "Documents", "folder": {}}, + {"name": "readme.txt"}, + ]) + + dialog = CloudFolderPickerDialog(config) + + assert dialog._tree.topLevelItemCount() == 2 + source = dialog.cloud_source() + assert source == {"provider": "onedrive", "site_id": "", "site_name": "", "remote_path": ""} + + +def test_cloud_folder_picker_navigates_into_a_folder(qapp, config, monkeypatch) -> None: + monkeypatch.setattr(ms365_auth, "get_access_token", lambda tenant_id, client_id: "fake-token") + + def fake_list(token, path=""): + if path == "": + return [{"name": "Documents", "folder": {}}] + if path == "Documents": + return [{"name": "report.docx"}] + return [] + + monkeypatch.setattr(graph, "list_onedrive_files", fake_list) + + dialog = CloudFolderPickerDialog(config) + folder_item = dialog._tree.topLevelItem(0) + dialog._on_item_activated(folder_item, 0) + + assert dialog._current_remote_path() == "Documents" + assert dialog.cloud_source()["remote_path"] == "Documents" diff --git a/theme/qss.py b/theme/qss.py index ee76110..5559592 100644 --- a/theme/qss.py +++ b/theme/qss.py @@ -45,7 +45,7 @@ QWidget#contentArea { background: $bg; } carries a 2px accent marker, so which section you are in survives even at a glance or for anyone who cannot separate the two greys. */ QWidget#navWrap QTreeWidget::item, QWidget#navWrap QListWidget::item { - padding: 6px 4px; border-radius: ${radius}px; + padding: 6px 10px; border-radius: ${radius}px; } QWidget#navWrap QTreeWidget::item:hover, QWidget#navWrap QListWidget::item:hover { background: $nav_hover; diff --git a/ui/cloud_folder_picker_dialog.py b/ui/cloud_folder_picker_dialog.py new file mode 100644 index 0000000..8298407 --- /dev/null +++ b/ui/cloud_folder_picker_dialog.py @@ -0,0 +1,212 @@ +"""DF-007 — browse OneDrive/SharePoint via Microsoft Graph and pick a folder +to use as (a local mirror of) a project's working directory. See +``core/cloud_workspace_sync.py`` for the mirror/sync side and +``ui/ms365_signin_dialog.py`` for the sign-in gate this calls first. +""" +from __future__ import annotations + +from typing import Optional + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, + QRadioButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, +) + +from ..core import ms365_auth +from ..core import ms365_graph as graph +from ..i18n import tr +from .icons import icon +from .ms365_signin_dialog import ensure_signed_in + +_ITEM_KIND = Qt.UserRole +_ITEM_NAME = Qt.UserRole + 1 + + +class CloudFolderPickerDialog(QDialog): + """Chọn "OneDrive của tôi" hoặc tìm 1 site SharePoint, rồi duyệt thư mục + con của nó; "Chọn thư mục này" trả về thư mục ĐANG HIỂN THỊ (không phải + dòng đang bôi đen — giống hành vi ``QFileDialog`` khi đang ở trong 1 + thư mục).""" + + def __init__(self, config, parent=None): + super().__init__(parent) + self._config = config + ms365 = (config.ms365 if config is not None else {}) or {} + self._token = ms365_auth.get_access_token( + ms365.get("tenant_id", ""), ms365.get("client_id", "")) + self._site_id = "" + self._site_name = "" + self._path_parts: list[str] = [] # relative path segments from the drive root + + self.setWindowTitle(tr("cloud_picker.title")) + self.setModal(True) + self.resize(520, 480) + layout = QVBoxLayout(self) + + source_row = QHBoxLayout() + self._onedrive_radio = QRadioButton(tr("cloud_picker.source_onedrive")) + self._onedrive_radio.setChecked(True) + self._onedrive_radio.toggled.connect(self._on_source_toggled) + self._sharepoint_radio = QRadioButton(tr("cloud_picker.source_sharepoint")) + source_row.addWidget(self._onedrive_radio) + source_row.addWidget(self._sharepoint_radio) + source_row.addStretch(1) + layout.addLayout(source_row) + + search_row = QHBoxLayout() + self._site_search = QLineEdit() + self._site_search.setPlaceholderText(tr("cloud_picker.search_sites_placeholder")) + self._site_search.setEnabled(False) + self._site_search.returnPressed.connect(self._search_sites) + self._search_btn = QPushButton(tr("cloud_picker.search_btn")) + self._search_btn.setEnabled(False) + self._search_btn.clicked.connect(self._search_sites) + search_row.addWidget(self._site_search, 1) + search_row.addWidget(self._search_btn) + layout.addLayout(search_row) + + self._path_lbl = QLabel() + self._path_lbl.setWordWrap(True) + layout.addWidget(self._path_lbl) + + self._tree = QTreeWidget() + self._tree.setHeaderHidden(True) + self._tree.itemDoubleClicked.connect(self._on_item_activated) + layout.addWidget(self._tree, 1) + + btn_row = QHBoxLayout() + self._choose_btn = QPushButton(tr("cloud_picker.choose_here")) + self._choose_btn.setObjectName("primary") + self._choose_btn.setIcon(icon("cloud")) + self._choose_btn.clicked.connect(self.accept) + self._cancel_btn = QPushButton(tr("cloud_picker.cancel")) + self._cancel_btn.clicked.connect(self.reject) + btn_row.addStretch(1) + btn_row.addWidget(self._cancel_btn) + btn_row.addWidget(self._choose_btn) + layout.addLayout(btn_row) + + self._refresh_path_label() + self._reload() + + # ---- source switching ------------------------------------------------- + def _on_source_toggled(self, _checked: bool) -> None: + is_sharepoint = self._sharepoint_radio.isChecked() + self._site_search.setEnabled(is_sharepoint) + self._search_btn.setEnabled(is_sharepoint) + self._choose_btn.setEnabled(not is_sharepoint or bool(self._site_id)) + if not is_sharepoint: + self._site_id = "" + self._site_name = "" + self._path_parts = [] + self._refresh_path_label() + self._reload() + + def _search_sites(self) -> None: + query = self._site_search.text().strip() + if not query: + return + self._tree.clear() + try: + sites = graph.list_sharepoint_sites(self._token, query) + except graph.Ms365GraphError as exc: + QMessageBox.warning(self, tr("cloud_picker.title"), + tr("cloud_picker.load_failed", err=str(exc))) + return + if not sites: + item = QTreeWidgetItem([tr("cloud_picker.no_sites")]) + item.setData(0, _ITEM_KIND, "empty") + self._tree.addTopLevelItem(item) + return + for site in sites: + item = QTreeWidgetItem([site.get("displayName") or site.get("name") or site.get("id", "")]) + item.setIcon(0, icon("globe")) + item.setData(0, _ITEM_KIND, "site") + item.setData(0, _ITEM_NAME, site.get("id", "")) + item.setData(0, Qt.UserRole + 2, site.get("displayName") or site.get("name") or "") + self._tree.addTopLevelItem(item) + self._choose_btn.setEnabled(False) # must pick a site before choosing a folder + + def _on_item_activated(self, item: QTreeWidgetItem, _col: int) -> None: + kind = item.data(0, _ITEM_KIND) + if kind == "site": + self._site_id = item.data(0, _ITEM_NAME) + self._site_name = item.data(0, Qt.UserRole + 2) + self._path_parts = [] + self._choose_btn.setEnabled(True) + self._refresh_path_label() + self._reload() + elif kind == "up": + self._path_parts.pop() + self._refresh_path_label() + self._reload() + elif kind == "folder": + self._path_parts.append(item.data(0, _ITEM_NAME)) + self._refresh_path_label() + self._reload() + # kind == "file": not navigable, double-click does nothing + + # ---- listing ------------------------------------------------------------ + def _current_remote_path(self) -> str: + return "/".join(self._path_parts) + + def _refresh_path_label(self) -> None: + if self._sharepoint_radio.isChecked(): + root = self._site_name or tr("cloud_picker.source_sharepoint") + else: + root = tr("cloud_picker.source_onedrive") + parts = "/".join([root] + self._path_parts) + self._path_lbl.setText(parts) + + def _reload(self) -> None: + self._tree.clear() + if self._sharepoint_radio.isChecked() and not self._site_id: + return # waiting on a site search/selection + if self._path_parts: + up = QTreeWidgetItem([tr("cloud_picker.up")]) + up.setData(0, _ITEM_KIND, "up") + self._tree.addTopLevelItem(up) + try: + if self._sharepoint_radio.isChecked(): + children = graph.list_sharepoint_files( + self._token, self._site_id, self._current_remote_path()) + else: + children = graph.list_onedrive_files(self._token, self._current_remote_path()) + except graph.Ms365GraphError as exc: + QMessageBox.warning(self, tr("cloud_picker.title"), + tr("cloud_picker.load_failed", err=str(exc))) + return + for entry in children: + name = entry.get("name", "") + if not name: + continue + is_folder = "folder" in entry + item = QTreeWidgetItem([name]) + item.setIcon(0, icon("folder" if is_folder else "file")) + item.setData(0, _ITEM_KIND, "folder" if is_folder else "file") + item.setData(0, _ITEM_NAME, name) + self._tree.addTopLevelItem(item) + + # ---- result --------------------------------------------------------- + def cloud_source(self) -> dict: + """Chỉ gọi sau khi ``exec()`` trả về ``QDialog.Accepted``.""" + if self._sharepoint_radio.isChecked(): + return { + "provider": "sharepoint", "site_id": self._site_id, + "site_name": self._site_name, "remote_path": self._current_remote_path(), + } + return {"provider": "onedrive", "site_id": "", "site_name": "", + "remote_path": self._current_remote_path()} + + +def pick_cloud_folder(parent, config) -> Optional[dict]: + """Đảm bảo đã đăng nhập MS365 rồi mở dialog duyệt; trả về ``cloud_source`` + dict nếu người dùng xác nhận một thư mục, ``None`` nếu hủy hoặc chưa đăng + nhập.""" + if not ensure_signed_in(parent, config): + return None + dialog = CloudFolderPickerDialog(config, parent) + if dialog.exec() == QDialog.Accepted: + return dialog.cloud_source() + return None diff --git a/ui/file_edit_dialog.py b/ui/file_edit_dialog.py index 5465e1a..ea6fbe4 100644 --- a/ui/file_edit_dialog.py +++ b/ui/file_edit_dialog.py @@ -96,11 +96,20 @@ class FileEditDialog(QDialog): # ---- AI edit row --------------------------------------------------- ai_row = QHBoxLayout() + ai_row.setContentsMargins(0, 6, 0, 2) self.instruction_edit = QLineEdit() self.instruction_edit.setPlaceholderText(tr("fileedit.instruction_placeholder")) + self.instruction_edit.setMinimumHeight(38) + self.instruction_edit.setStyleSheet( + "QLineEdit { padding: 8px 12px; font-size: 13px; border-radius: 6px; }" + ) self.instruction_edit.returnPressed.connect(self._ai_edit) self.ai_btn = QPushButton(tr("fileedit.ai_btn")) self.ai_btn.setIcon(icon("sparkle")) + self.ai_btn.setMinimumHeight(38) + self.ai_btn.setStyleSheet( + "QPushButton { padding: 0 16px; font-size: 13px; border-radius: 6px; }" + ) self.ai_btn.clicked.connect(self._ai_edit) ai_row.addWidget(self.instruction_edit, 1) ai_row.addWidget(self.ai_btn) diff --git a/ui/ms365_signin_dialog.py b/ui/ms365_signin_dialog.py new file mode 100644 index 0000000..a81531b --- /dev/null +++ b/ui/ms365_signin_dialog.py @@ -0,0 +1,137 @@ +"""Microsoft 365 sign-in dialog (DF-007) — thin UI over the working device-code +flow in ``core/ms365_auth.py``. There was an older MS365 sign-in UI in this +app; it was removed as dead code (no entry point — see +``ui/settings_dialog.py`` module docstring) before this feature existed, so +this is a fresh, small dialog rather than a resurrection of that one. + +Usage: ``if ensure_signed_in(parent, ctx.config): ...`` — returns ``True`` +immediately (no dialog shown) when already signed in. +""" +from __future__ import annotations + +import webbrowser + +from PySide6.QtCore import QThread, Signal +from PySide6.QtWidgets import ( + QDialog, QHBoxLayout, QLabel, QMessageBox, QPushButton, QVBoxLayout, +) + +from ..core import ms365_auth +from ..i18n import tr + + +class _SignInWorker(QThread): + """Chạy ``sign_in_device_code()`` (blocking, poll tới khi xong/hết hạn) ở + luồng nền — xem ``core/worker.py::AgentWorker`` cho cùng idiom (bắt hết + exception, phát signal thay vì để lỗi giết luồng âm thầm).""" + code_ready = Signal(dict) + finished_ok = Signal(dict) + failed = Signal(str) + + def __init__(self, config, parent=None): + super().__init__(parent) + self._config = config + + def run(self) -> None: # noqa: D401 + try: + result = ms365_auth.sign_in(lambda flow: self.code_ready.emit(flow), self._config) + self.finished_ok.emit(result or {}) + except Exception as exc: # noqa: BLE001 - surfaced to the UI, never crashes the thread + self.failed.emit(str(exc)) + + +class Ms365SignInDialog(QDialog): + """Modal: hiện user_code + verification_uri, tự mở trình duyệt, đóng lại + khi đăng nhập xong (hoặc người dùng bấm Hủy).""" + + def __init__(self, config, parent=None): + super().__init__(parent) + self._config = config + self._worker: _SignInWorker | None = None + self.setWindowTitle(tr("ms365_signin.title")) + self.setModal(True) + + layout = QVBoxLayout(self) + self._intro_lbl = QLabel(tr("ms365_signin.intro")) + self._intro_lbl.setWordWrap(True) + layout.addWidget(self._intro_lbl) + + self._code_lbl = QLabel() + self._code_lbl.setWordWrap(True) + self._code_lbl.hide() + layout.addWidget(self._code_lbl) + + self._open_link_btn = QPushButton(tr("ms365_signin.open_link")) + self._open_link_btn.hide() + self._open_link_btn.clicked.connect(self._open_link) + layout.addWidget(self._open_link_btn) + + self._error_lbl = QLabel() + self._error_lbl.setWordWrap(True) + self._error_lbl.setStyleSheet("color: #c0392b;") + self._error_lbl.hide() + layout.addWidget(self._error_lbl) + + btn_row = QHBoxLayout() + self._signin_btn = QPushButton(tr("ms365_signin.button")) + self._signin_btn.setObjectName("primary") + self._signin_btn.clicked.connect(self._start_sign_in) + self._cancel_btn = QPushButton(tr("ms365_signin.cancel")) + self._cancel_btn.clicked.connect(self.reject) + btn_row.addStretch(1) + btn_row.addWidget(self._cancel_btn) + btn_row.addWidget(self._signin_btn) + layout.addLayout(btn_row) + + self._verification_uri = "" + + def _open_link(self) -> None: + if self._verification_uri: + webbrowser.open(self._verification_uri) + + def _start_sign_in(self) -> None: + self._signin_btn.setEnabled(False) + self._signin_btn.setText(tr("ms365_signin.signing_in")) + self._error_lbl.hide() + self._worker = _SignInWorker(self._config, self) + self._worker.code_ready.connect(self._on_code_ready) + self._worker.finished_ok.connect(self._on_finished_ok) + self._worker.failed.connect(self._on_failed) + self._worker.start() + + def _on_code_ready(self, flow: dict) -> None: + self._verification_uri = flow.get("verification_uri_complete") or flow.get( + "verification_uri", "") + self._code_lbl.setText( + tr("ms365_signin.code_hint", url=flow.get("verification_uri", "")) + + f"\n\n{flow.get('user_code', '')}") + self._code_lbl.show() + self._open_link_btn.show() + if self._verification_uri: + webbrowser.open(self._verification_uri) + + def _on_finished_ok(self, _result: dict) -> None: + self.accept() + + def _on_failed(self, err: str) -> None: + self._signin_btn.setEnabled(True) + self._signin_btn.setText(tr("ms365_signin.button")) + self._error_lbl.setText(tr("ms365_signin.failed", err=err)) + self._error_lbl.show() + + def reject(self) -> None: + # NOTE: MSAL's acquire_token_by_device_flow() has no cancellation hook, + # so a worker already polling keeps polling in the background until it + # times out on its own (a few minutes) — closing this dialog just stops + # the UI from waiting on it. Its late signals are harmless no-ops + # against an already-closed (but not destroyed) dialog. + super().reject() + + +def ensure_signed_in(parent, config) -> bool: + """True nếu đã (hoặc vừa) đăng nhập MS365; False nếu người dùng hủy hoặc + đăng nhập thất bại và đóng dialog.""" + if ms365_auth.is_signed_in(config): + return True + dialog = Ms365SignInDialog(config, parent) + return dialog.exec() == QDialog.Accepted diff --git a/ui/workspace_tab.py b/ui/workspace_tab.py index 1c295aa..5334158 100644 --- a/ui/workspace_tab.py +++ b/ui/workspace_tab.py @@ -309,6 +309,28 @@ class WorkspaceTab(QWidget): folder_row.addWidget(self._open_btn) rl.addLayout(folder_row) + # DF-007 — an ALTERNATIVE way to set output_dir: browse OneDrive/ + # SharePoint via Graph API and download a local mirror instead of + # picking an already-local folder. output_dir still always ends up a + # real local path (see Project.cloud_source) — nothing downstream + # (run_command/read_file/...) needs to know the difference. + cloud_row = QHBoxLayout() + self._cloud_pick_btn = QPushButton() + self._cloud_pick_btn.setIcon(icon("cloud")) + self._cloud_pick_btn.clicked.connect(self._pick_cloud_folder) + self._cloud_sync_btn = QPushButton() + self._cloud_sync_btn.setIcon(icon("refresh")) + self._cloud_sync_btn.clicked.connect(self._sync_cloud_folder) + self._cloud_sync_btn.hide() + cloud_row.addWidget(self._cloud_pick_btn) + cloud_row.addWidget(self._cloud_sync_btn) + cloud_row.addStretch(1) + rl.addLayout(cloud_row) + self._cloud_badge_lbl = QLabel() + self._cloud_badge_lbl.setWordWrap(True) + self._cloud_badge_lbl.hide() + rl.addWidget(self._cloud_badge_lbl) + rl.addStretch(1) # the drawing floats Lưu project at the bottom save_row = QHBoxLayout() self._save_btn = QPushButton() @@ -494,6 +516,9 @@ class WorkspaceTab(QWidget): self._browse_btn.setText(tr("workspace.browse")) self._browse_btn.setToolTip(tr("workspace.browse_tooltip")) self._open_btn.setText(tr("workspace.open_folder")) + self._cloud_pick_btn.setText(tr("workspace.cloud_pick")) + self._cloud_sync_btn.setText(tr("workspace.cloud_sync")) + self._refresh_cloud_badge() self._save_btn.setText(tr("workspace.save")) self._proj_collapse_btn.setToolTip(tr("workspace.collapse_projects_tooltip")) self._projects_strip.setToolTip(tr("workspace.expand_projects_tooltip")) @@ -692,6 +717,7 @@ class WorkspaceTab(QWidget): self.instr_edit.clear() self.folder_lbl.setText("") self._del_btn.setEnabled(False) + self._refresh_cloud_badge(project) # Show Cowork/GraphRAG ONLY when a project is actually selected. self._update_tab_visibility(project is not None) # Bind the embedded Cowork/GraphRAG/History to this project's sandbox. @@ -948,3 +974,105 @@ class WorkspaceTab(QWidget): wd = project.workspace_dir() wd.mkdir(parents=True, exist_ok=True) open_folder(str(wd)) + + def _cloud_token(self): + """Lấy access token MS365 hiện tại theo cấu hình — raise + ``Ms365AuthError`` nếu chưa đăng nhập/hết hạn (gọi picker trước nên + thường đã có sẵn phiên đăng nhập).""" + from ..core.ms365_auth import get_access_token + + ms365 = (self.ctx.config.ms365 or {}) + return get_access_token(ms365.get("tenant_id", ""), ms365.get("client_id", "")) + + def _pick_cloud_folder(self) -> None: + """DF-007 — duyệt OneDrive/SharePoint qua Graph API, tải một bản + mirror cục bộ xuống rồi dùng bản mirror đó làm output_dir của + project. Xem core/cloud_workspace_sync.py cho giới hạn (một chiều, + thủ công, không đồng bộ liên tục, không xử lý xung đột).""" + from ..core.cloud_workspace_sync import download_folder + from ..core.ms365_auth import Ms365AuthError + from ..core.projects import WORKSPACES_DIR, load_project, save_project + from .cloud_folder_picker_dialog import pick_cloud_folder + + pid = self._current_id + project = load_project(pid) if pid else None + if project is None: + return + cloud_source = pick_cloud_folder(self, self.ctx.config) + if not cloud_source: + return + local_dir = WORKSPACES_DIR / project.project_id / "_cloud_mirror" + self._cloud_pick_btn.setEnabled(False) + try: + token = self._cloud_token() + report = download_folder(token, cloud_source, local_dir) + except Ms365AuthError as exc: + QMessageBox.warning(self, tr("workspace.cloud_pick"), str(exc)) + return + finally: + self._cloud_pick_btn.setEnabled(True) + project.output_dir = str(local_dir) + project.cloud_source = cloud_source + save_project(project) + self.folder_lbl.setText(str(local_dir)) + self._refresh_cloud_badge(project) + self.projects_changed.emit() + if report.errors: + QMessageBox.warning(self, tr("workspace.cloud_pick"), + tr("workspace.cloud_sync_errors", n=len(report.errors))) + self.status_message.emit(tr("workspace.saved", name=project.name)) + + def _sync_cloud_folder(self) -> None: + """DF-007 — đẩy thay đổi cục bộ lên cloud rồi tải lại (một chiều mỗi + bước, thủ công, chạy khi bấm nút). Không xoá file 2 phía, không phát + hiện xung đột — xem core/cloud_workspace_sync.py.""" + from ..core.cloud_workspace_sync import download_folder, upload_folder + from ..core.ms365_auth import Ms365AuthError + from ..core.projects import load_project + + pid = self._current_id + project = load_project(pid) if pid else None + if project is None or not project.cloud_source: + return + self._cloud_sync_btn.setEnabled(False) + try: + token = self._cloud_token() + up_report = upload_folder(token, project.cloud_source, project.workspace_dir()) + down_report = download_folder(token, project.cloud_source, project.workspace_dir()) + except Ms365AuthError as exc: + QMessageBox.warning(self, tr("workspace.cloud_sync"), str(exc)) + return + finally: + self._cloud_sync_btn.setEnabled(True) + lines = [tr("workspace.cloud_sync_result", + up=up_report.transferred, down=down_report.transferred)] + n_errors = len(up_report.errors) + len(down_report.errors) + if n_errors: + lines.append(tr("workspace.cloud_sync_errors", n=n_errors)) + n_skipped = len(up_report.skipped_too_large) + if n_skipped: + lines.append(tr("workspace.cloud_sync_skipped", n=n_skipped)) + QMessageBox.information(self, tr("workspace.cloud_sync"), "\n".join(lines)) + + def _refresh_cloud_badge(self, project=None) -> None: + """Hiện/ẩn badge ☁ + nút Đồng bộ theo project đang mở có phải một + mirror cloud hay không (``project.cloud_source``).""" + if project is None: + from ..core.projects import load_project + + project = load_project(self._current_id) if self._current_id else None + cloud_source = project.cloud_source if project is not None else None + if not cloud_source: + self._cloud_badge_lbl.hide() + self._cloud_sync_btn.hide() + return + if cloud_source.get("provider") == "sharepoint": + text = tr("workspace.cloud_badge_sharepoint", + site=cloud_source.get("site_name", ""), + path=cloud_source.get("remote_path", "") or "/") + else: + text = tr("workspace.cloud_badge_onedrive", + path=cloud_source.get("remote_path", "") or "/") + self._cloud_badge_lbl.setText(text) + self._cloud_badge_lbl.show() + self._cloud_sync_btn.show()