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>
This commit is contained in:
2026-08-30 10:41:45 +09:00
co-authored by Claude Opus 5
parent d20306be08
commit e29a0ccdbd
264 changed files with 4593 additions and 359 deletions
@@ -36,6 +36,11 @@ class AiEditModelResolver:
"""
def __init__(self, ctx, model_combo, on_status, confirm_switch) -> None:
"""Bộ chọn model của panel AI-Edit.
``on_status`` và ``confirm_switch`` được tiêm vào để lớp này không tự mở hộp
thoại — nhờ vậy test chạy được nó mà không cần giao diện.
"""
self.ctx = ctx
self._combo = model_combo
self._on_status = on_status
@@ -50,10 +55,12 @@ class AiEditModelResolver:
@property
def models(self) -> List[str]:
"""Danh sách model đã nạp cho provider hiện tại."""
return self._models
@property
def models_provider(self) -> str:
"""Provider mà danh sách model đang thuộc về; '' nếu chưa nạp lần nào."""
return self._models_provider
@property
@@ -64,6 +71,7 @@ class AiEditModelResolver:
@property
def routed_model(self) -> Optional[str]:
"""Model do định tuyến tự động chọn cho lượt này; ``None`` nếu dùng model người dùng chọn."""
return self._routed_model
def should_refresh(self) -> bool:
@@ -80,6 +88,7 @@ class AiEditModelResolver:
setting_model = self.ctx.config.provider_conf(name).get("model", "")
def job(worker):
"""Chạy nền: hỏi provider danh sách model, lỗi thì trả về list rỗng."""
prov = self.ctx.build_provider_for(name)
try:
models = list(getattr(prov, "list_models", lambda: [])() or [])
@@ -88,6 +97,11 @@ class AiEditModelResolver:
return {"models": models}
def done(res):
"""Đổ danh sách vào ô chọn, giữ nguyên model đang chọn.
Luôn đưa model cấu hình trong Settings lên đầu, kể cả khi provider không
liệt kê được — để ô chọn không bao giờ chỉ có mỗi "(tự động)".
"""
fetched = list(res.get("models", []))
# Always offer the Settings-configured model as an explicit
# choice, even when the provider can't list models.
@@ -111,6 +125,11 @@ class AiEditModelResolver:
self._scan_all_image_models()
def _scan_all_image_models(self, then_suggest: bool = False) -> None:
"""Dò khắp mọi provider đã cấu hình xem cái nào có model sinh ảnh.
Chạy sẵn ở nền để lúc cần gợi ý là có ngay. Đang dò dở mà bị gọi lại thì
chỉ ghi nhận yêu cầu gợi ý, không dò chồng lên nhau.
"""
if self._img_scan_worker is not None:
if then_suggest:
self._pending_img_suggest = True
@@ -120,6 +139,7 @@ class AiEditModelResolver:
if (c.get("base_url") or c.get("api_key"))]
def job(worker):
"""Chạy nền: duyệt từng provider, lọc ra model sinh ảnh được."""
from cowork_local.core import image_gen
found = []
for key in candidates:
@@ -134,6 +154,7 @@ class AiEditModelResolver:
return {"found": found}
def done(res):
"""Ghi lại kết quả dò; có yêu cầu gợi ý đang chờ thì trả lời luôn."""
self._img_scan_worker = None
self._all_image_models = list(res.get("found", []))
if self._pending_img_suggest:
@@ -149,6 +170,9 @@ class AiEditModelResolver:
w.start()
def _on_image_scan_failed(self, _err) -> None:
"""Dò model ảnh thất bại: chỉ xoá cờ đang chạy, không báo lỗi ra màn hình —
đây là việc chạy ngầm mà người dùng không yêu cầu.
"""
self._img_scan_worker = None
def provider(self) -> Any:
@@ -208,6 +232,10 @@ class AiEditModelResolver:
self._scan_all_image_models(then_suggest=True)
def _suggest_cross_provider_image(self) -> None:
"""Gợi ý một model sinh ảnh, kể cả khi nó thuộc provider KHÁC provider đang chọn.
Đây là chỗ duy nhất trong màn Thư mục biết tới nhiều provider cùng lúc.
"""
from cowork_local.config import PROVIDER_LABELS
if not self._all_image_models:
picked = self._combo.currentData()
+28
View File
@@ -36,6 +36,11 @@ class AiEditPipeline:
"""
def __init__(self, owner) -> None:
"""Vòng chạy một lượt sửa tệp bằng AI.
``pending`` giữ nội dung model đề xuất cho tới khi người dùng đồng ý: không
bao giờ ghi đè tệp trước khi có xác nhận.
"""
self._owner = owner
self.worker: Optional[AgentWorker] = None
self.pending: Optional[dict] = None # proposed content awaiting confirmation
@@ -72,6 +77,7 @@ class AiEditPipeline:
# ---- usage accounting (like Cowork's per-message footer) --------------- #
def _add_usage(self, usage) -> None:
"""Cộng dồn token của một lượt gọi vào tổng của cả phiên sửa file."""
if not isinstance(usage, dict):
return
tot = self._prompt_usage
@@ -81,6 +87,7 @@ class AiEditPipeline:
tot["cost"] += float(usage.get("cost_usd", 0.0) or 0.0)
def _show_usage(self, bubble) -> None:
"""Gắn dòng token/chi phí dưới bong bóng trả lời; chưa dùng token nào thì bỏ qua."""
tot = self._prompt_usage
if bubble is None or not (tot["in"] or tot["out"]):
return
@@ -96,12 +103,14 @@ class AiEditPipeline:
# ---- phase 1: plan ------------------------------------------------------- #
def _run_plan(self) -> None:
"""Bước 1 — nhờ model lập kế hoạch sửa trước khi động vào nội dung."""
o = self._owner
c = self._ctx
plan_bubble = o.ai_chat.add_plan(tr("folder.ai_planning"))
o.ai_chat.scroll_to_bottom()
def job(worker):
"""Chạy nền: gọi model sinh kế hoạch và đo token đã dùng."""
from cowork_local.core import usage_tracker as ut
from cowork_local.core.co4e_runner import _usage_delta
provider = c["provider"]
@@ -131,6 +140,7 @@ class AiEditPipeline:
worker.start()
def _plan_done(self, result, plan_bubble) -> None:
"""Có kế hoạch: hiện ra rồi chuyển sang bước sửa thật."""
self._add_usage((result or {}).get("usage"))
plan = ((result or {}).get("plan") or "").strip()
self._ctx["plan"] = plan
@@ -140,6 +150,7 @@ class AiEditPipeline:
# ---- phase 2: execute (edit the file) ------------------------------------ #
def _run_edit(self) -> None:
"""Bước 2 — sinh nội dung mới, phát dần vào bong bóng trả lời."""
o = self._owner
c = self._ctx
bubble = o.ai_chat.add_assistant(tr("folder.ai_edit"))
@@ -178,6 +189,7 @@ class AiEditPipeline:
pass
def job(worker):
"""Chạy nền: gọi model sinh nội dung sửa, kèm ngữ cảnh tệp đang mở."""
provider = c["provider"]
open_note = (f"the currently-open file '{c['filename']}'" if c["filename"]
else "no file is open")
@@ -199,6 +211,7 @@ class AiEditPipeline:
messages.append({"role": "user", "content": cur + f"Request: {c['instruction']}"})
def on_text(piece: str) -> None:
"""Đẩy từng mẩu nội dung ra ngoài để giao diện vẽ dần."""
worker.emit_event({"type": "text", "delta": piece})
from cowork_local.core import usage_tracker as ut
@@ -221,11 +234,17 @@ class AiEditPipeline:
worker.start()
def _stream(self, ev, bubble) -> None:
"""Vẽ từng mẩu nội dung đang phát dần và cuộn theo."""
if isinstance(ev, dict) and ev.get("type") == "text":
bubble.append_delta(ev.get("delta", ""))
self._owner.ai_chat.scroll_to_bottom()
def _done(self, result, bubble) -> None:
"""Sinh xong: tách phần mã khỏi phần giải thích và dựng bản xem trước.
KHÔNG bao giờ ghi thẳng ra tệp ở đây — chỉ ``_finalize_apply`` mới chạm đĩa,
và chỉ sau khi người dùng bấm Áp dụng.
"""
o = self._owner
self.worker = None
o.set_busy(False)
@@ -281,6 +300,10 @@ class AiEditPipeline:
self._finalize_apply(p)
def _generate_then_finalize(self, p: dict) -> None:
"""Lượt sửa có yêu cầu sinh ảnh: tạo ảnh trước rồi mới ghi nội dung.
Phải theo thứ tự đó vì nội dung có thể tham chiếu tới đường dẫn ảnh vừa tạo.
"""
o = self._owner
imgs = p.get("image_gens") or []
root = os.path.normpath(o.preview.root)
@@ -289,6 +312,7 @@ class AiEditPipeline:
o.status_message.emit(tr("folder.ai_generating"))
def job(worker):
"""Chạy nền: sinh lần lượt từng ảnh được yêu cầu."""
from cowork_local.core import image_gen
results = []
for prompt, rel in imgs:
@@ -314,6 +338,7 @@ class AiEditPipeline:
worker.start()
def _images_done(self, res: dict, p: dict) -> None:
"""Ảnh đã sinh xong: ghi nội dung ra đĩa."""
o = self._owner
self.worker = None
o.set_busy(False)
@@ -329,6 +354,7 @@ class AiEditPipeline:
o.preview.open_file(created[0], reset_ai=False)
def _finalize_apply(self, p: dict, images_done: bool = False) -> None:
"""Ghi nội dung đã được duyệt xuống đĩa và làm mới khung xem."""
o = self._owner
content = p.get("content")
target = p.get("target")
@@ -352,6 +378,7 @@ class AiEditPipeline:
o.flag_done()
def discard(self) -> None:
"""Bỏ bản đề xuất đang chờ, không chạm vào tệp."""
o = self._owner
self.pending = None
o.show_confirm_row(False)
@@ -361,6 +388,7 @@ class AiEditPipeline:
o.maybe_dequeue() # discarding resolves the gate → run the next queued edit
def _failed(self, err, bubble) -> None:
"""Lượt sửa lỗi: hiện lỗi trong bong bóng và mở khoá lại panel."""
o = self._owner
self.worker = None
bubble.set_markdown(tr("folder.ai_error", err=err))
@@ -49,6 +49,10 @@ class AiFileEditorDialog(QWidget):
badge_changed = Signal(str) # "" | " ⏳" | " ✓" — the shell mirrors this onto its toggle button
def __init__(self, ctx, preview, cowork=None, parent=None):
"""Hộp thoại sửa tệp bằng AI.
``resolver`` dựng sau, vì nó cần chính ô chọn model mà hàm này mới tạo.
"""
super().__init__(parent)
self.ctx = ctx
self.preview = preview
@@ -118,6 +122,7 @@ class AiFileEditorDialog(QWidget):
self.retranslate()
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self._ai_title.setText(tr("folder.ai_edit"))
self.ai_input.setPlaceholderText(tr("folder.ai_placeholder"))
self.ai_send_btn.setText(tr("folder.ai_send"))
@@ -161,6 +166,7 @@ class AiFileEditorDialog(QWidget):
# ---- send / queue -------------------------------------------------------- #
def _ai_send(self) -> None:
"""Gửi một yêu cầu sửa; chưa chọn thư mục làm việc thì báo lỗi ngay."""
if not self.preview.root:
self.ai_chat.add_error(tr("folder.ai_no_file"))
return
@@ -179,6 +185,7 @@ class AiFileEditorDialog(QWidget):
self.pipeline.start(instruction)
def _update_queue_status(self) -> None:
"""Cập nhật dòng trạng thái theo số yêu cầu còn xếp hàng."""
n = len(self._ai_queue)
if n:
self._ai_status.setText("⏳ " + tr("folder.ai_status_running")
@@ -197,6 +204,7 @@ class AiFileEditorDialog(QWidget):
# ---- pipeline callbacks (see ai_edit_pipeline.py) ------------------------- #
def set_busy(self, busy: bool) -> None:
"""Khoá/mở ô nhập và nút gửi trong lúc một lượt sửa đang chạy."""
self.ai_input.setEnabled(not busy)
self.ai_send_btn.setEnabled(not busy)
if busy:
@@ -219,9 +227,11 @@ class AiFileEditorDialog(QWidget):
self.badge_changed.emit(" ✓")
def show_confirm_row(self, visible: bool) -> None:
"""Hiện/ẩn hàng nút Áp dụng · Huỷ cho bản đề xuất đang chờ."""
self._ai_confirm_row.setVisible(visible)
def set_review_status(self, text: str, color) -> None:
"""Đặt dòng trạng thái kèm màu (xanh khi xong, đỏ khi lỗi)."""
self._ai_status.setText(text)
if color:
self._ai_status.setStyleSheet(f"color:{color};")
+30
View File
@@ -17,6 +17,7 @@ _MAX_HIGHLIGHT_CHARS = 300_000 # skip colouring huge files (keeps typing sna
# ── VS-Code-Dark+-ish token palette ────────────────────────────────────────
def _fmt(color: str, *, italic: bool = False, bold: bool = False) -> QTextCharFormat:
"""Dựng một định dạng ký tự cho bộ tô màu cú pháp."""
f = QTextCharFormat()
f.setForeground(QColor(color))
if italic:
@@ -31,6 +32,11 @@ class PygmentsHighlighter(QSyntaxHighlighter):
full text (debounced) so multi-line strings/comments colour correctly."""
def __init__(self, document):
"""Tô màu cú pháp bằng Pygments.
Tô lại sau một nhịp trễ (``QTimer`` chạy một lần) thay vì ngay mỗi phím: gõ
nhanh trong file lớn mà tô đồng bộ thì giao diện khựng.
"""
super().__init__(document)
from pygments.lexers.special import TextLexer
self._lexer = TextLexer(stripnl=False)
@@ -44,6 +50,7 @@ class PygmentsHighlighter(QSyntaxHighlighter):
@staticmethod
def _build_rules():
"""Dựng bảng ánh xạ loại token của Pygments sang màu theo theme đang dùng."""
from pygments.token import (
Comment, Error, Keyword, Name, Number, Operator, Punctuation, String,
)
@@ -67,6 +74,7 @@ class PygmentsHighlighter(QSyntaxHighlighter):
]
def set_filename(self, filename: str, text: str = "") -> None:
"""Chọn bộ phân tích cú pháp theo đuôi tệp; không nhận ra thì tắt tô màu."""
from pygments.lexers import get_lexer_for_filename, guess_lexer
from pygments.lexers.special import TextLexer
from pygments.util import ClassNotFound
@@ -80,12 +88,18 @@ class PygmentsHighlighter(QSyntaxHighlighter):
self._retokenize()
def _fmt_for(self, tok):
"""Định dạng của một token; leo dần lên token cha cho tới khi khớp một luật."""
for ttype, fmt in self._rules:
if tok in ttype:
return fmt
return None
def _retokenize(self) -> None:
"""Phân tích lại toàn bộ tài liệu và ghi nhớ định dạng cho từng dòng.
Pygments phân tích theo cả tệp chứ không theo từng dòng, nên không thể
tô đúng nếu chỉ nhìn một dòng — ví dụ chuỗi nhiều dòng hay khối chú thích.
"""
from pygments import lex
text = self.document().toPlainText()
self._ranges = []
@@ -99,6 +113,7 @@ class PygmentsHighlighter(QSyntaxHighlighter):
self.rehighlight()
def highlightBlock(self, text: str) -> None: # noqa: N802 - Qt override
"""Áp định dạng đã tính sẵn cho một dòng. Qt gọi hàm này cho từng dòng hiện trên màn."""
if not self._ranges:
return
bstart = self.currentBlock().position()
@@ -113,14 +128,18 @@ class PygmentsHighlighter(QSyntaxHighlighter):
class _LineNumbers(QWidget):
"""Máng số dòng vẽ bên trái ô soạn thảo."""
def __init__(self, editor):
"""Dải số dòng bên trái ô soạn mã."""
super().__init__(editor)
self._editor = editor
def sizeHint(self) -> QSize:
"""Bề rộng máng số dòng, do ô soạn thảo tính theo số chữ số của dòng cuối."""
return QSize(self._editor.line_number_width(), 0)
def paintEvent(self, event): # noqa: N802
"""Nhờ ô soạn thảo vẽ — nó mới biết dòng nào đang hiện ở đâu."""
self._editor.paint_line_numbers(event)
@@ -129,6 +148,11 @@ class CodeEditor(QPlainTextEdit):
the Sublime/VS-Code look for viewing & editing source files."""
def __init__(self):
"""Ô soạn mã: phông đều, không tự xuống dòng, tab rộng 4 ký tự.
Không tự xuống dòng là cố ý — mã bị bẻ dòng thì lệch thụt đầu dòng và khó
đọc hơn là phải cuộn ngang.
"""
super().__init__()
self.setObjectName("codeEditor")
self.setLineWrapMode(QPlainTextEdit.NoWrap)
@@ -144,13 +168,16 @@ class CodeEditor(QPlainTextEdit):
self._update_gutter_width()
def line_number_width(self) -> int:
"""Bề rộng cần cho máng số dòng, tính theo số chữ số của dòng cuối cùng."""
digits = max(2, len(str(max(1, self.blockCount()))))
return 12 + self.fontMetrics().horizontalAdvance("9") * digits
def _update_gutter_width(self) -> None:
"""Chừa lề trái đúng bằng bề rộng máng số dòng."""
self.setViewportMargins(self.line_number_width(), 0, 0, 0)
def _on_update_request(self, rect, dy: int) -> None:
"""Cuộn hoặc vẽ lại vùng nào thì máng số dòng cuộn/vẽ lại đúng vùng đó."""
if dy:
self._gutter.scroll(0, dy)
else:
@@ -159,11 +186,13 @@ class CodeEditor(QPlainTextEdit):
self._update_gutter_width()
def resizeEvent(self, event): # noqa: N802
"""Đổi kích thước thì đặt lại hình chữ nhật của máng số dòng."""
super().resizeEvent(event)
cr = self.contentsRect()
self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.line_number_width(), cr.height()))
def paint_line_numbers(self, event) -> None:
"""Vẽ số của các dòng đang hiện trên màn, bỏ qua dòng bị gập hoặc nằm ngoài khung."""
p = current_palette()
painter = QPainter(self._gutter)
painter.fillRect(event.rect(), QColor(p.code_gutter_bg))
@@ -183,6 +212,7 @@ class CodeEditor(QPlainTextEdit):
num += 1
def load_file(self, path: str, text: str) -> None:
"""Nạp nội dung tệp vào ô soạn thảo và bật tô màu theo đuôi tệp."""
self.setPlainText(text)
self._highlighter.set_filename(path, text)
@@ -57,6 +57,11 @@ class DocumentPreviewManager(QWidget):
ai_reset_requested = Signal() # a DIFFERENT file was opened by the user
def __init__(self, root: str, parent=None):
"""Khung xem tài liệu.
Mọi lượt đọc tệp đi qua ``FileWorkspaceService`` nên không thoát ra ngoài
thư mục gốc, kể cả khi đường dẫn có ``..``.
"""
super().__init__(parent)
self._root = root
self._file_service = FileWorkspaceService(WorkspaceSession.unscoped(Path(root)))
@@ -123,6 +128,7 @@ class DocumentPreviewManager(QWidget):
self.retranslate()
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho các nút và ô trống."""
self.save_btn.setText(tr("folder.save"))
self.ext_btn.setText(tr("folder.open_external"))
if not self._current_file:
@@ -130,23 +136,28 @@ class DocumentPreviewManager(QWidget):
self._retranslate_mode_btn()
def _retranslate_mode_btn(self) -> None:
"""Nhãn nút chuyển chế độ đổi theo trạng thái: đang xem thì ghi "Sửa" và ngược lại."""
self.mode_btn.setText(tr("folder.edit") if not self.mode_btn.isChecked()
else tr("folder.preview"))
# ---- public API used by the shell / AI panel --------------------------- #
@property
def current_file(self) -> Optional[str]:
"""Đường dẫn tệp đang mở; ``None`` nếu chưa mở tệp nào."""
return self._current_file
@property
def edit_kind(self) -> Optional[str]:
"""Loại nội dung đang sửa ('pptx', 'html', 'text'…); ``None`` nếu đang chỉ xem."""
return self._edit_kind
@property
def root(self) -> str:
"""Thư mục gốc mà mọi thao tác ghi phải nằm bên trong."""
return self._root
def set_root(self, root: str) -> None:
"""Đổi thư mục gốc và dựng lại dịch vụ ghi tệp gắn với phạm vi mới."""
self._root = root
self._file_service = FileWorkspaceService(WorkspaceSession.unscoped(Path(root)))
@@ -155,6 +166,13 @@ class DocumentPreviewManager(QWidget):
# (reset_ai=False when the AI itself just CREATED this file — keep
# that chat). Whether/how to reset is the AI panel's own business —
# this class only announces that a genuine file switch happened.
"""Mở một tệp, tự chọn cách hiển thị theo đuôi và kích thước.
Thứ tự ưu tiên: ảnh → HTML → PowerPoint → Excel → tài liệu Office → nhị
phân (quá lớn hoặc không phải văn bản) → mã nguồn. Đổi sang tệp KHÁC thì
báo cho panel AI biết để nó bắt đầu hội thoại mới; ``reset_ai=False`` dùng
khi chính AI vừa tạo ra tệp này — giữ nguyên đoạn chat đang dở.
"""
if reset_ai and path != self._current_file:
self.ai_reset_requested.emit()
self._current_file = path
@@ -206,6 +224,7 @@ class DocumentPreviewManager(QWidget):
return False
def save(self) -> None:
"""Lưu nội dung đang sửa xuống tệp; pptx ghi ngược vào bản trình chiếu."""
if not self._current_file:
return
try:
@@ -261,6 +280,7 @@ class DocumentPreviewManager(QWidget):
return dest
def open_external(self) -> None:
"""Mở tệp đang xem bằng ứng dụng mặc định của hệ điều hành."""
if self._current_file:
from cowork_local.ui.osutil import open_location
open_location(self._current_file)
@@ -278,6 +298,7 @@ class DocumentPreviewManager(QWidget):
# ---- simple renderers (HTML/PPTX/Excel/PDF/office live in
# office_document_renderer.py) --------------------------------------------- #
def _show_code(self, path: str) -> None:
"""Hiện tệp trong ô soạn thảo có tô màu cú pháp, cho phép sửa và lưu."""
text = read_text(path)
self.editor.setReadOnly(False)
self.editor.load_file(path, text)
@@ -285,6 +306,7 @@ class DocumentPreviewManager(QWidget):
self.stack.setCurrentWidget(self.editor)
def _show_image(self, path: str) -> None:
"""Hiện ảnh; ảnh hỏng hoặc không đọc được thì rơi về khung nhị phân."""
from PySide6.QtGui import QPixmap
pix = QPixmap(path)
if pix.isNull():
@@ -296,6 +318,7 @@ class DocumentPreviewManager(QWidget):
self.stack.setCurrentWidget(self._img_scroll)
def _show_binary(self, path: str) -> None:
"""Hiện thông báo "tệp nhị phân" kèm nút mở bằng ứng dụng ngoài."""
self._placeholder.setText(tr("folder.binary_file"))
self.ext_btn.setVisible(True)
self.stack.setCurrentWidget(self._placeholder)
+9
View File
@@ -30,6 +30,7 @@ class FolderTab(QWidget):
status_message = Signal(str)
def __init__(self, ctx: AppContext, cowork=None):
"""Ghép ba phần của tab Thư mục: cây tệp, khung xem và terminal."""
super().__init__()
self.ctx = ctx
self._root = str(ctx.config.cowork_output_dir())
@@ -90,6 +91,11 @@ class FolderTab(QWidget):
# ---- public API ---------------------------------------------------------
def set_root(self, path: str) -> None:
"""Đổi thư mục gốc của cả ba widget con.
``WorkspaceFileTree`` lặng lẽ từ chối đường dẫn không hợp lệ, nên chỉ lan
sang khung xem và terminal khi cây thật sự đã nhận.
"""
self.tree.set_root(path)
# WorkspaceFileTree silently no-ops on an invalid path (same guard
# the original single-class _root setter had) — mirror that here by
@@ -100,6 +106,7 @@ class FolderTab(QWidget):
self.terminal.set_cwd(path)
def _toggle_ai_panel(self) -> None:
"""Gập/mở panel AI-Edit; mở ra thì báo cho panel biết để nó nạp model lần đầu."""
show = self.ai_btn.isChecked()
self.ai_panel.setVisible(show)
if show:
@@ -107,9 +114,11 @@ class FolderTab(QWidget):
self.ai_panel.on_opened()
def _on_ai_badge_changed(self, suffix: str) -> None:
"""Cập nhật huy hiệu trên nút AI-Edit (số việc đang chờ)."""
self.ai_btn.setText(tr("folder.ai_edit") + suffix)
def _retranslate(self) -> None:
"""Chuyển lệnh dịch lại xuống cho cả ba widget con."""
self.tree.retranslate()
self.preview.retranslate()
self.ai_panel.retranslate()
@@ -44,6 +44,11 @@ class OfficeDocumentRenderer:
"""
def __init__(self, owner) -> None:
"""Bộ hiện tài liệu Office.
Có cache PDF vì mỗi lần chuyển đổi phải gọi LibreOffice — xem lại cùng một
tệp mà chuyển lại từ đầu thì chờ vài giây mỗi lượt.
"""
self._owner = owner
self._engine = None
self._pdf_view = None
@@ -54,6 +59,7 @@ class OfficeDocumentRenderer:
self._xlsx_view = None
def show_html(self, path: str, mode_preview: bool) -> None:
"""Hiện tệp HTML: xem đã dựng hình hoặc sửa mã nguồn, tuỳ ``mode_preview``."""
o = self._owner
o._edit_kind = "html"
o.mode_btn.setVisible(True)
@@ -111,6 +117,7 @@ class OfficeDocumentRenderer:
return self._engine
def toggle_edit_mode(self) -> None:
"""Lật giữa Xem và Sửa cho tệp đang mở (HTML và PowerPoint)."""
o = self._owner
if not o.current_file:
return
@@ -194,9 +201,14 @@ class OfficeDocumentRenderer:
src, out_dir = path, self._pdf_tmp
def job(worker):
"""Chạy nền: chuyển tài liệu Office sang PDF bằng LibreOffice."""
return {"src": src, "mtime": mtime, "pdf": convert_to_pdf(src, out_dir)}
def done(result):
"""Hiện PDF vừa chuyển và ghi vào bộ nhớ đệm theo (đường dẫn, thời điểm sửa).
Bỏ kết quả nếu người dùng đã chuyển sang tệp khác trong lúc chờ chuyển đổi.
"""
if result.get("src") != o.current_file:
return # user moved on to another file
pdf = result.get("pdf")
@@ -213,6 +225,9 @@ class OfficeDocumentRenderer:
worker.start()
def _ensure_pdf_view(self):
"""Dựng khung xem PDF một lần duy nhất; không có ``QtPdf`` thì trả ``None``
để chỗ gọi rơi về cách hiện văn bản thuần.
"""
if not HAS_PDF:
return None
if self._pdf_view is None:
@@ -230,6 +245,7 @@ class OfficeDocumentRenderer:
return self._pdf_view
def _render_pdf(self, pdf_path: str) -> None:
"""Nạp và hiện một tệp PDF; thiếu ``QtPdf`` thì rơi về trích văn bản."""
view = self._ensure_pdf_view()
if view is None:
self.show_document_text(pdf_path)
@@ -238,6 +254,10 @@ class OfficeDocumentRenderer:
self._owner.stack.setCurrentWidget(view)
def show_document_text(self, path: str) -> None:
"""Cách dự phòng cuối: trích văn bản từ tài liệu và hiện dưới dạng chữ thuần.
Dùng khi không có LibreOffice để chuyển PDF, hoặc không có khung xem PDF.
"""
from cowork_local.core.doc_extract import extract_text
o = self._owner
try:
@@ -29,6 +29,7 @@ class WorkspaceFileTree(QWidget):
root_changed = Signal(str) # absolute path of the new root
def __init__(self, initial_root: str, parent=None):
"""Cây tệp của thư mục làm việc, có ô đường dẫn ở trên làm tiêu đề màn hình."""
super().__init__(parent)
self._root = initial_root
@@ -65,14 +66,20 @@ class WorkspaceFileTree(QWidget):
self.retranslate()
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self._open_btn.setToolTip(tr("folder.path_placeholder"))
self._open_btn.setText(tr("folder.open_folder"))
@property
def root(self) -> str:
"""Thư mục gốc đang hiện."""
return self._root
def set_root(self, path: str) -> None:
"""Đổi thư mục gốc; đường dẫn rỗng hoặc không tồn tại thì BỎ QUA lặng lẽ.
Chỗ gọi dựa vào việc ``root`` không đổi để biết cây đã từ chối đường dẫn.
"""
p = str(path or "").strip()
if not p or not os.path.isdir(p):
return
@@ -84,11 +91,13 @@ class WorkspaceFileTree(QWidget):
self.root_changed.emit(p)
def _pick_root(self) -> None:
"""Mở hộp thoại chọn thư mục gốc."""
chosen = QFileDialog.getExistingDirectory(self, tr("folder.open_folder"), self._root)
if chosen:
self.set_root(chosen)
def _on_tree_clicked(self, index) -> None:
"""Bấm vào một mục: là tệp thì phát tín hiệu mở, là thư mục thì để cây tự bung."""
path = self.model.filePath(index)
if path and os.path.isfile(path):
self.file_selected.emit(path)