refactor(folder): R08-T12 — folder_tab.py 1589 -> 305, tách 8 file

presentation/folder/
      ai_edit_runner.py            325  một lượt AI sửa file, từ gửi tới xem trước
      document_preview_manager.py  317  PDF/Word/Excel/PowerPoint/ảnh/HTML/mã
      ai_file_editor_dialog.py     317  dựng panel AI + chọn model
      code_editor.py               183  ô soạn mã, đánh số dòng, tô cú pháp
      ai_output_writer.py          140  phần DUY NHẤT chạm vào file người dùng
      image_model_picker.py        115  dò model sinh ảnh trên mọi provider
      file_helpers.py              112  nhận dạng loại file + ngưỡng
      workspace_file_tree.py        38  cây thư mục
    ui/folder_tab.py               305  lắp ráp + retranslate

Plan ghi 3 file; khối lượng thật cần 8. Hai file tôi thêm ngoài dự kiến vì
đọc kỹ thì chúng là ranh giới thật:

* ai_output_writer.py — tách ra vì đây là phần duy nhất THẬT SỰ ghi đè file
  của người dùng. Mọi thứ trước nó chỉ dựng bản xem trước. Ranh giới đó đáng
  nhìn thấy trong cấu trúc thư mục.
* image_model_picker.py — chỗ duy nhất trong màn Thư mục biết tới nhiều
  provider cùng lúc (nó gợi ý được model sinh ảnh của provider KHÁC cái đang
  chọn).

Gom mọi hằng nhận dạng loại file (_IMAGE_SUFFIXES, _HAS_PDF, _MAX_EDIT_BYTES…)
về file_helpers.py: cả tám file trong gói đều hỏi tới, để rải ra thì thêm một
đuôi file phải sửa vài chỗ.

LẠI IMPORT LAZY THỤT LỀ: regex đổi mức tương đối của tôi chỉ khớp đầu dòng
nên bỏ sót import nằm trong thân hàm — 3 checker đỏ. Lần này tôi sửa một lượt
cho CẢ cây presentation/ thay vì riêng thư mục vừa tách; nó tìm ra thêm 3 file
ở scheduling cũng đang sai mà chưa nổ.

756 test xanh. 24/24 checker qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-27 23:23:06 +09:00
co-authored by Claude Opus 5
parent 982fecc8dc
commit f0fd3a41cd
9 changed files with 1566 additions and 1298 deletions
@@ -0,0 +1,317 @@
"""Hiển thị nội dung file theo từng loại — R08-T12.
PDF, Word, Excel, PowerPoint, ảnh, HTML, mã nguồn, và nhị phân. Mỗi loại
một đường riêng vì cách đọc khác hẳn nhau.
Điểm cần biết: ``_ensure_engine`` và ``_ensure_pdf_view`` dựng lười —
QtWebEngine và bộ đọc PDF đều nặng, mở thư mục toàn file .txt thì không
nên trả giá cho chúng.
Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``.
"""
from __future__ import annotations
from .file_helpers import (
DOC_SUFFIXES, _EXCEL_SUFFIXES, _HAS_PDF, _HAS_WEB, _HTML_SUFFIXES, _IMAGE_SUFFIXES, _MAX_EDIT_BYTES, _PPTX_SUFFIXES, _is_probably_text, _pptx_available, _read_text,
)
import os
from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QTableWidget, QTableWidgetItem, QTabWidget, QTextBrowser
from ...core.worker import AgentWorker
from ...i18n import tr
from ...ui.libreoffice_view import DOC_SUFFIXES
class DocumentPreviewMixin:
"""Trộn vào FolderTab."""
def open_file(self, path: str, reset: bool = True) -> None:
# Switching to a DIFFERENT file starts a fresh AI-edit conversation, so
# the previous file's chat can't bleed into (hallucinate) the new file.
# (reset=False when the AI just CREATED this file — keep that chat.)
if reset and path != self._current_file:
self._reset_ai_conversation()
self._current_file = path
self.file_label.setText(path)
suffix = Path(path).suffix.lower()
self.mode_btn.setVisible(False)
self.save_btn.setVisible(False)
self.ext_btn.setVisible(False)
self._edit_kind = None
try:
size = os.path.getsize(path)
except OSError:
size = 0
if suffix in _IMAGE_SUFFIXES:
self._show_image(path)
elif suffix in _HTML_SUFFIXES:
self._show_html(path, mode_preview=True)
elif suffix in _PPTX_SUFFIXES and _pptx_available():
self._show_pptx(path, mode_preview=True)
elif suffix in _EXCEL_SUFFIXES:
self._show_excel(path)
elif suffix in DOC_SUFFIXES:
self._show_document(path)
elif size > _MAX_EDIT_BYTES or not _is_probably_text(path):
self._show_binary(path)
else:
self._show_code(path)
def _show_code(self, path: str) -> None:
text = _read_text(path)
self.editor.setReadOnly(False)
self.editor.load_file(path, text)
self.save_btn.setVisible(True)
self.stack.setCurrentWidget(self.editor)
def _show_html(self, path: str, mode_preview: bool) -> None:
self._edit_kind = "html"
self.mode_btn.setVisible(True)
self.mode_btn.setChecked(not mode_preview) # checked = Edit
self._retranslate_mode_btn()
if mode_preview:
from PySide6.QtCore import QUrl
html = _read_text(path)
engine = self._ensure_engine()
if engine is not None:
engine.setHtml(html, QUrl.fromLocalFile(path))
self.stack.setCurrentWidget(engine)
else:
self.web.setHtml(html)
self.stack.setCurrentWidget(self.web)
self.save_btn.setVisible(False)
else:
self._show_code(path)
def _show_pptx(self, path: str, mode_preview: bool) -> None:
"""PPTX: Preview renders the slides (PDF via LibreOffice); Edit shows the
deck's text (marker-delimited per box) in the editor. Saving/AI-editing
writes the text back into the .pptx silently (no PowerPoint window)."""
self._edit_kind = "pptx"
self.mode_btn.setVisible(True)
self.mode_btn.setChecked(not mode_preview) # checked = Edit
self._retranslate_mode_btn()
self.ext_btn.setVisible(True)
if mode_preview:
self._show_document(path) # PDF render of the slides
self.mode_btn.setVisible(True) # _show_document doesn't touch it
else:
from ...core.pptx_edit import pptx_to_text
try:
text = pptx_to_text(path)
except Exception as exc: # noqa: BLE001
text = f"[could not read pptx text: {exc}]"
self.editor.setReadOnly(False)
self.editor.load_file(path + ".txt", text) # .txt → plain highlighting
self.save_btn.setVisible(True)
self.stack.setCurrentWidget(self.editor)
def _ensure_engine(self):
"""Create the QWebEngineView on first HTML preview (only when WebEngine
is safe to use); otherwise stay on the QTextBrowser fallback."""
if not _HAS_WEB:
return None
if self._engine is None:
try:
from PySide6.QtWebEngineWidgets import QWebEngineView
self._engine = QWebEngineView()
self.stack.addWidget(self._engine)
except Exception: # noqa: BLE001
self._engine = None
return self._engine
def _toggle_edit_mode(self) -> None:
if not self._current_file:
return
preview = not self.mode_btn.isChecked() # checked = Edit
if self._edit_kind == "pptx":
self._show_pptx(self._current_file, mode_preview=preview)
else:
self._show_html(self._current_file, mode_preview=preview)
def _show_excel(self, path: str) -> None:
"""View a spreadsheet as a real TABLE (openpyxl) — one tab per sheet — so
Excel is viewable WITHOUT LibreOffice/PowerPoint. Bounded rows/cols keep
large workbooks snappy. Falls back to the document (PDF/text) path if the
workbook can't be read."""
self.ext_btn.setVisible(True)
try:
from ...core.deps import ensure_module
ensure_module("openpyxl", "openpyxl")
from openpyxl import load_workbook
wb = load_workbook(path, read_only=True, data_only=True)
except Exception: # noqa: BLE001 - no openpyxl / unreadable → try PDF/text
self._show_document(path)
return
MAX_ROWS, MAX_COLS = 2000, 100
if self._xlsx_view is None:
self._xlsx_view = QTabWidget()
self.stack.addWidget(self._xlsx_view)
tabs = self._xlsx_view
while tabs.count():
w = tabs.widget(0); tabs.removeTab(0); w.deleteLater()
try:
for ws in wb.worksheets:
rows = list(ws.iter_rows(max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True))
ncols = max((len(r) for r in rows), default=0)
table = QTableWidget(len(rows), ncols)
table.setEditTriggers(QTableWidget.NoEditTriggers)
table.horizontalHeader().setVisible(False)
for r, row in enumerate(rows):
for c, val in enumerate(row):
if val is not None:
table.setItem(r, c, QTableWidgetItem(str(val)))
table.resizeColumnsToContents()
title = ws.title + (" (…)" if (ws.max_row or 0) > MAX_ROWS
or (ws.max_column or 0) > MAX_COLS else "")
tabs.addTab(table, title)
finally:
wb.close()
if tabs.count() == 0:
self._show_document(path)
return
self.stack.setCurrentWidget(tabs)
def _show_document(self, path: str) -> None:
"""Office docs (ppt/pptx/doc/docx/xls/…) + PDF are RENDERED via QtPdf —
LibreOffice converts them to PDF first. Falls back to text extraction
when QtPdf/LibreOffice aren't available."""
self.ext_btn.setVisible(True)
suffix = Path(path).suffix.lower()
if not _HAS_PDF:
self._show_document_text(path)
return
if suffix == ".pdf":
self._render_pdf(path)
return
# Cached conversion (per path+mtime) → render immediately.
try:
mtime = os.path.getmtime(path)
except OSError:
mtime = 0
cached = self._pdf_cache.get((path, mtime))
if cached and os.path.exists(cached):
self._render_pdf(cached)
return
# Convert to PDF off the UI thread (LibreOffice → MS Office COM). Only
# skip to text when NEITHER is possible (no LibreOffice AND not Windows,
# where COM may drive an installed Office). This is what lets a large
# .pptx/.docx render via MS Office when LibreOffice isn't installed.
from ...core.doc_extract import convert_to_pdf, find_soffice
if not find_soffice() and os.name != "nt":
self._show_document_text(path)
return
self.doc_view.setPlainText(tr("folder.converting"))
self.stack.setCurrentWidget(self.doc_view)
if self._pdf_tmp is None:
import tempfile
self._pdf_tmp = tempfile.mkdtemp(prefix="cowork_folder_pdf_")
src, out_dir = path, self._pdf_tmp
def job(worker):
return {"src": src, "mtime": mtime, "pdf": convert_to_pdf(src, out_dir)}
def done(result):
if result.get("src") != self._current_file:
return # user moved on to another file
pdf = result.get("pdf")
if pdf:
self._pdf_cache[(result["src"], result["mtime"])] = pdf
self._render_pdf(pdf)
else:
self._show_document_text(src)
worker = AgentWorker(job)
worker.finished_ok.connect(done)
worker.failed.connect(lambda _e, p=src: self._show_document_text(p))
self._convert_worker = worker
worker.start()
def _ensure_pdf_view(self):
if not _HAS_PDF:
return None
if self._pdf_view is None:
from PySide6.QtPdf import QPdfDocument
from PySide6.QtPdfWidgets import QPdfView
self._pdf_doc = QPdfDocument(self)
self._pdf_view = QPdfView(self)
self._pdf_view.setDocument(self._pdf_doc)
try:
self._pdf_view.setPageMode(QPdfView.PageMode.MultiPage)
self._pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
except Exception: # noqa: BLE001 - enum names vary slightly across versions
pass
self.stack.addWidget(self._pdf_view)
return self._pdf_view
def _render_pdf(self, pdf_path: str) -> None:
view = self._ensure_pdf_view()
if view is None:
self._show_document_text(pdf_path)
return
self._pdf_doc.load(pdf_path)
self.stack.setCurrentWidget(view)
def _show_document_text(self, path: str) -> None:
from ...core.doc_extract import extract_text
try:
text, note = extract_text(path)
except Exception as exc: # noqa: BLE001
text, note = None, str(exc)
body = text if text else tr("folder.doc_unreadable", note=note or "?")
self.doc_view.setPlainText(body)
self.stack.setCurrentWidget(self.doc_view)
def _show_image(self, path: str) -> None:
from PySide6.QtGui import QPixmap
pix = QPixmap(path)
if pix.isNull():
self._show_binary(path)
return
self._img_label.setPixmap(pix)
self._img_label.resize(pix.size())
self.ext_btn.setVisible(True)
self.stack.setCurrentWidget(self._img_scroll)
def _show_binary(self, path: str) -> None:
self._placeholder.setText(tr("folder.binary_file"))
self.ext_btn.setVisible(True)
self.stack.setCurrentWidget(self._placeholder)
def _save(self) -> None:
if not self._current_file:
return
try:
if self._edit_kind == "pptx":
if not self._write_pptx(self.editor.toPlainText()):
return
else:
Path(self._current_file).write_text(self.editor.toPlainText(), encoding="utf-8")
self.status_message.emit(tr("folder.saved", name=Path(self._current_file).name))
except Exception as exc: # noqa: BLE001
self.status_message.emit(tr("folder.save_error", err=str(exc)))
def _write_pptx(self, content: str, skip_confirm: bool = False) -> bool:
"""Write edited pptx text back into the deck. If the edit REPLACES any
image, ask the user to confirm first (image edits are gated so a future
image-processing model can't touch pictures without an explicit OK).
``skip_confirm`` is used when the image was already confirmed (e.g. just
generated). Returns False if the user declined."""
from ...core import pptx_edit
if not skip_confirm and pptx_edit.image_change_requested(content):
from PySide6.QtWidgets import QMessageBox
ok = QMessageBox.question(self, tr("folder.ai_image_confirm_title"),
tr("folder.ai_image_confirm"))
if ok != QMessageBox.Yes:
self.status_message.emit(tr("folder.ai_image_declined"))
return False
pptx_edit.apply_text_to_pptx(self._current_file, content)
return True
def _open_external(self) -> None:
if self._current_file:
from ...ui.osutil import open_location
open_location(self._current_file)