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
+114
View File
@@ -0,0 +1,114 @@
"""Hàm phụ trợ đọc và nhận dạng file — R08-T12.
Thuần hàm, không widget. ``_is_probably_text`` là chỗ quyết định file
mở bằng ô soạn thảo hay báo là nhị phân — đoán sai thì người dùng thấy
một màn hình ký tự rác.
"""
from __future__ import annotations
from ...ui.libreoffice_view import DOC_SUFFIXES # noqa: F401 — dùng lại ở cả gói
# Nhận dạng loại file và các ngưỡng — gom về đây vì cả sáu file trong gói
# đều hỏi tới, để rải ra thì mỗi lần thêm một đuôi file phải sửa vài chỗ.
try:
from ...graph.graph_web import _HAS_WEB
except Exception: # pragma: no cover
_HAS_WEB = False
try:
from PySide6.QtPdf import QPdfDocument # noqa: F401
from PySide6.QtPdfWidgets import QPdfView # noqa: F401
_HAS_PDF = True
except Exception: # pragma: no cover - QtPdf not bundled
_HAS_PDF = False
_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico"}
_HTML_SUFFIXES = {".html", ".htm"}
_PPTX_SUFFIXES = {".pptx"} # text-editable in-app via python-pptx (no PowerPoint)
_EXCEL_SUFFIXES = {".xlsx", ".xlsm"} # viewed as a real table via openpyxl (no LibreOffice)
_MAX_EDIT_BYTES = 2_000_000 # above this, view read-only / note only
_MAX_HIGHLIGHT_CHARS = 300_000 # skip colouring huge files (keeps typing snappy)
import os
from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QFont, QTextCharFormat
from ...i18n import tr
def _fmt(color: str, *, italic: bool = False, bold: bool = False) -> QTextCharFormat:
f = QTextCharFormat()
f.setForeground(QColor(color))
if italic:
f.setFontItalic(True)
if bold:
f.setFontWeight(QFont.Bold)
return f
def _pptx_available() -> bool:
"""True when python-pptx is importable. If it's MISSING, auto-download &
install it (via deps.ensure_module) so pptx editing 'just works' — cached so
the (one-time) install is attempted only once."""
global _PPTX_READY
if _PPTX_READY is None:
try:
from ...core.deps import ensure_module
_PPTX_READY = ensure_module("pptx", "python-pptx") is not None
except Exception: # noqa: BLE001
_PPTX_READY = False
return _PPTX_READY
def _split_code_block(text: str):
"""Split an AI reply into ``(file_content, summary)``. ``file_content`` is
the first fenced code block (the edited file); ``summary`` is any prose
before it. Returns ``(None, text)`` when there's no code block."""
import re
m = re.search(r"```[^\n]*\n(.*?)```", text or "", re.DOTALL)
if not m:
return None, (text or "")
return m.group(1), (text[:m.start()].strip())
def _parse_ai_output(text: str):
"""Parse an AI edit reply into ``(target, content, summary, image_gens)``.
``FILE: <path>`` names a NEW file to create; ``IMAGE_GEN: <prompt> => <path>``
lines request generated illustration images (relative paths)."""
import re
content, summary = _split_code_block(text)
target = None
m = re.search(r"(?mi)^\s*FILE:\s*(.+?)\s*$", text or "")
if m:
target = m.group(1).strip().strip("`\"'")
image_gens = []
for gm in re.finditer(r"(?mi)^\s*IMAGE_GEN:\s*(.+?)\s*=>\s*(\S+)\s*$", text or ""):
image_gens.append((gm.group(1).strip(), gm.group(2).strip().strip("`\"'")))
# Strip the directive lines out of the shown summary.
summary = re.sub(r"(?mi)^\s*(FILE|IMAGE_GEN):\s*.+?$", "", summary).strip()
return target, content, summary, image_gens
def _read_text(path: str) -> str:
try:
return Path(path).read_text(encoding="utf-8", errors="replace")
except OSError as exc:
return f"[could not read file: {exc}]"
def _is_probably_text(path: str) -> bool:
try:
with open(path, "rb") as f:
chunk = f.read(4096)
except OSError:
return False
if b"\x00" in chunk:
return False
try:
chunk.decode("utf-8")
return True
except UnicodeDecodeError:
# Latin-ish text still edits fine via errors="replace"; only reject on
# a hard binary signal (NUL above), so most source files pass.
return True