Files
cowork-local/ui/file_edit_dialog.py
T
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

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

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

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

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-08-31 05:15:13 +00: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))