Files
cowork-local/ui/file_edit_dialog.py
T
anhtnm1andClaude Opus 5 e29a0ccdbd refactor: vá 4 hồi quy, tách 4 file chạm trần LOC, docstring lên 100%
Hồi quy đã vá
-------------
F-12  Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
      `chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
      `composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
      tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
      là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
      gắn 1 tệp, khớp bản trước refactor.

F-01  Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
      `folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
      `ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
      tiên hỏng thì đổi provider chính là lúc phải thử lại.

F-07  Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
      lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
      người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
      theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
      mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
      `check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.

F-11  Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
      mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
      và không bao giờ chạy.

Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:

    graph_renderer.py         -> graph_scene_builder.py + graph_export.py
    co4e_workflow_service.py  -> co4e_run_history.py
    json_config_repository.py -> config_sections.py
    agents_admin_tab.py       -> shared/agent_kind_visuals.py

File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.

Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.

Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.

859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:41:45 +09:00

254 lines
11 KiB
Python

"""View a file inside the app and edit it with AI.
Opened from the Files pane's context menu (Input/Output lists in Cowork chat).
Plain-text files load into an editable viewer; binary documents (docx/pdf/…)
show their EXTRACTED text read-only (view + ask-about, but no save — writing
extracted text back would destroy the original format).
"AI Edit" sends the current content + the user's instruction to the active
provider and replaces the editor content with the model's full rewrite — the
user reviews it and clicks Save (a one-time ``.bak`` backup of the original is
written next to the file the first time it's saved from this dialog).
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Optional
from PySide6.QtWidgets import (
QDialog, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox,
QPlainTextEdit, QPushButton, QVBoxLayout,
)
from ..core.worker import AgentWorker
from ..i18n import tr
from .icons import icon
_MAX_TEXT_BYTES = 2_000_000 # bigger files: view the head, no editing
_FENCE_RE = re.compile(r"^\s*```[\w-]*\n(.*)\n```\s*$", re.DOTALL)
# Binary document formats: always view via text extraction, never edit as plain
# text (they'd be corrupted) — even a small one that happens to utf-8-decode.
_BINARY_DOC_SUFFIXES = {".pdf", ".doc", ".docx", ".docm", ".xls", ".xlsx", ".xlsm",
".ppt", ".pptx", ".odt", ".ods", ".odp"}
_SYSTEM_PROMPT = (
"You edit files. You are given the FULL current content of a file and an "
"instruction. Apply the instruction and reply with ONLY the complete new "
"file content — no explanations, no markdown code fences, no preamble. "
"Keep everything the instruction doesn't ask to change byte-identical."
)
def _strip_fences(text: str) -> str:
"""Models sometimes wrap the whole reply in one ``` fence despite the
instructions — unwrap that single outer fence, leave anything else alone."""
m = _FENCE_RE.match(text or "")
return m.group(1) if m else (text or "")
class FileEditDialog(QDialog):
"""Pick/view a file and apply AI edits to it (see module docstring)."""
def __init__(self, ctx=None, path: str = "", parent=None):
"""Hộp thoại xem/sửa một tệp.
Mở ở chế độ CHỈ ĐỌC; muốn sửa phải bật rõ ràng, và lần ghi đầu tiên tự sao
lưu bản gốc.
"""
super().__init__(parent)
self.ctx = ctx
self._worker: Optional[AgentWorker] = None
self._editable = False
self._backed_up = False
self.setWindowTitle(tr("fileedit.title"))
self.resize(760, 620)
root = QVBoxLayout(self)
# ---- file row ----------------------------------------------------
row = QHBoxLayout()
self.path_edit = QLineEdit()
self.path_edit.setReadOnly(True)
self.browse_btn = QPushButton()
self.browse_btn.setIcon(icon("folder"))
self.browse_btn.setToolTip(tr("fileedit.browse_tooltip"))
self.browse_btn.setFixedWidth(34)
self.browse_btn.clicked.connect(self._browse)
self.reload_btn = QPushButton()
self.reload_btn.setIcon(icon("refresh"))
self.reload_btn.setToolTip(tr("fileedit.reload_tooltip"))
self.reload_btn.setFixedWidth(34)
self.reload_btn.clicked.connect(self._reload)
row.addWidget(self.path_edit, 1)
row.addWidget(self.browse_btn)
row.addWidget(self.reload_btn)
root.addLayout(row)
# ---- content -----------------------------------------------------
self.editor = QPlainTextEdit()
self.editor.setPlaceholderText(tr("fileedit.pick_hint"))
root.addWidget(self.editor, 1)
self.status_lbl = QLabel("")
self.status_lbl.setObjectName("hint")
self.status_lbl.setWordWrap(True)
root.addWidget(self.status_lbl)
# ---- AI edit row ---------------------------------------------------
ai_row = QHBoxLayout()
self.instruction_edit = QLineEdit()
self.instruction_edit.setPlaceholderText(tr("fileedit.instruction_placeholder"))
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.clicked.connect(self._ai_edit)
ai_row.addWidget(self.instruction_edit, 1)
ai_row.addWidget(self.ai_btn)
root.addLayout(ai_row)
# ---- actions -------------------------------------------------------
btn_row = QHBoxLayout()
self.save_btn = QPushButton(tr("fileedit.save_btn"))
self.save_btn.setIcon(icon("save"))
self.save_btn.setObjectName("primary")
self.save_btn.clicked.connect(self._save)
self.close_btn = QPushButton(tr("fileedit.close_btn"))
self.close_btn.setIcon(icon("close"))
self.close_btn.clicked.connect(self.reject)
btn_row.addStretch(1)
btn_row.addWidget(self.save_btn)
btn_row.addWidget(self.close_btn)
root.addLayout(btn_row)
self._set_editable(False)
if path:
self.load_file(path)
# ---- loading -----------------------------------------------------------
def _browse(self) -> None:
"""Chọn tệp cần sửa."""
path, _ = QFileDialog.getOpenFileName(self, tr("fileedit.title"))
if path:
self.load_file(path)
def _reload(self) -> None:
"""Đọc lại tệp đang mở từ đĩa, bỏ mọi sửa đổi chưa lưu."""
if self.path_edit.text():
self.load_file(self.path_edit.text())
def load_file(self, path: str) -> None:
"""Load ``path``: text files editable; binary documents read-only via
the same extractor attachments use (doc_extract)."""
p = Path(path)
self.path_edit.setText(str(p))
self._backed_up = False
if not p.is_file():
self.editor.setPlainText("")
self._set_editable(False)
self.status_lbl.setText(tr("fileedit.not_found", path=str(p)))
return
raw = b""
is_text = False
if p.suffix.lower() not in _BINARY_DOC_SUFFIXES:
try:
raw = p.read_bytes()[:_MAX_TEXT_BYTES + 1]
text = raw.decode("utf-8")
is_text = "\x00" not in text
except (UnicodeDecodeError, OSError):
is_text = False
if is_text and len(raw) <= _MAX_TEXT_BYTES:
self.editor.setPlainText(text)
self._set_editable(True)
self.status_lbl.setText(tr("fileedit.loaded_editable"))
return
# Binary / oversized → extracted text, read-only.
from ..core.doc_extract import extract_text
try:
extracted, note = extract_text(str(p))
except Exception as exc: # noqa: BLE001 — viewing must never crash
extracted, note = None, str(exc)
self.editor.setPlainText(extracted or "")
self._set_editable(False)
self.status_lbl.setText(tr("fileedit.loaded_readonly")
+ (f" ({note})" if note else ""))
def _set_editable(self, editable: bool) -> None:
"""Bật/tắt chế độ sửa được (tệp nhị phân hoặc chỉ đọc thì tắt)."""
self._editable = editable
self.editor.setReadOnly(not editable)
self.save_btn.setEnabled(editable)
self.ai_btn.setEnabled(editable and self.ctx is not None)
self.instruction_edit.setEnabled(editable and self.ctx is not None)
# ---- AI edit -------------------------------------------------------------
def _ai_edit(self) -> None:
"""Nhờ AI sửa tệp theo yêu cầu; thiếu yêu cầu hoặc đang chạy dở thì bỏ qua."""
instruction = self.instruction_edit.text().strip()
if (not instruction or not self._editable or self.ctx is None
or self._worker is not None):
if not instruction and self._editable:
self.status_lbl.setText(tr("fileedit.needs_instruction"))
return
content = self.editor.toPlainText()
name = Path(self.path_edit.text()).name
self.ai_btn.setEnabled(False)
self.status_lbl.setText(tr("fileedit.ai_working"))
ctx = self.ctx
def job(worker: AgentWorker):
"""Chạy nền: gọi model sửa nội dung tệp theo yêu cầu."""
provider = ctx.build_active_provider()
messages = [
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content":
f"File: {name}\nInstruction: {instruction}\n\n"
f"--- CURRENT FILE CONTENT ---\n{content}"},
]
a = provider.chat(messages, tools=None, on_text=None, cancel=worker.is_cancelled)
return {"text": (a.get("content") or "").strip()}
def done(result: dict) -> None:
"""Đổ nội dung đã sửa vào ô soạn thảo, bỏ hàng rào code của model.
KHÔNG tự ghi đĩa: người dùng phải bấm Lưu.
"""
self._worker = None
self.ai_btn.setEnabled(True)
new_text = _strip_fences(result.get("text", ""))
if new_text:
self.editor.setPlainText(new_text)
self.status_lbl.setText(tr("fileedit.ai_done"))
else:
self.status_lbl.setText(tr("fileedit.ai_empty"))
def failed(err: str) -> None:
"""Sửa lỗi: hiện lý do (cắt ở 300 ký tự) và mở khoá lại nút."""
self._worker = None
self.ai_btn.setEnabled(True)
self.status_lbl.setText(tr("fileedit.ai_failed", err=err[:300]))
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(failed)
self._worker = w
w.start()
# ---- save -----------------------------------------------------------------
def _save(self) -> None:
"""Ghi nội dung đang sửa xuống tệp."""
path = self.path_edit.text()
if not path or not self._editable:
return
p = Path(path)
try:
# One-time backup of the on-disk original per dialog session.
if not self._backed_up and p.is_file():
bak = p.with_suffix(p.suffix + ".bak")
bak.write_bytes(p.read_bytes())
self._backed_up = True
p.write_text(self.editor.toPlainText(), encoding="utf-8")
self.status_lbl.setText(tr("fileedit.saved", path=p.name))
except OSError as exc:
QMessageBox.warning(self, tr("fileedit.title"), str(exc))