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>
318 lines
13 KiB
Python
318 lines
13 KiB
Python
"""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)
|