Files
cowork-local/ui/folder_tab.py
T
Nam Pham Dinh ThanhandClaude Opus 5 f0fd3a41cd 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>
2026-08-27 23:23:06 +09:00

309 lines
12 KiB
Python

"""Folder tab — a two-pane file explorer for the Workspace.
Left: a directory tree (QFileSystemModel). Right: view / edit the selected file
directly in the folder:
* **Source code + text/config + HTML source** — an editable code editor with
VS-Code-style syntax colouring (Pygments), line numbers, dark theme.
* **HTML** — a rendered Preview (WebEngine when available, else rich text) with
a Preview⇄Edit toggle.
* **Office docs** (doc/docx/ppt/pptx/xls/xlsx/pdf) — an in-app text preview
(extracted via the same parser attachments use) plus "Open externally" for
full-fidelity viewing.
* **Images** — shown inline.
Everything is best-effort and never raises: an unreadable/oversized/binary file
degrades to an explanatory note.
"""
from __future__ import annotations
from ..presentation.folder.code_editor import CodeEditor, PygmentsHighlighter, _LineNumbers
from ..presentation.folder.file_helpers import ( # noqa: F401 — giữ đường vào cũ
DOC_SUFFIXES, _EXCEL_SUFFIXES, _HAS_PDF, _HAS_WEB, _HTML_SUFFIXES,
_IMAGE_SUFFIXES, _MAX_EDIT_BYTES, _MAX_HIGHLIGHT_CHARS, _PPTX_SUFFIXES,
_fmt, _is_probably_text, _parse_ai_output, _pptx_available, _read_text,
_split_code_block,
)
from ..presentation.folder.workspace_file_tree import WorkspaceFileTreeMixin
from ..presentation.folder.document_preview_manager import DocumentPreviewMixin
from ..presentation.folder.ai_file_editor_dialog import AiFileEditorPanelMixin
from ..presentation.folder.ai_edit_runner import AiEditRunnerMixin
from ..presentation.folder.ai_output_writer import AiOutputWriterMixin
from ..presentation.folder.image_model_picker import ImageModelPickerMixin
import os
from pathlib import Path
from typing import Optional
from PySide6.QtCore import QRect, QSize, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QFont, QPainter, QSyntaxHighlighter, QTextCharFormat
from PySide6.QtWidgets import (
QComboBox, QFileSystemModel, QFileDialog, QHBoxLayout, QLabel, QLineEdit,
QPlainTextEdit, QPushButton, QScrollArea, QSplitter, QStackedWidget,
QTableWidget, QTableWidgetItem, QTabWidget, QTextBrowser, QTreeView,
QVBoxLayout, QWidget,
)
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from ..theme import current_palette
from .chat_view import ChatView
from .icons import icon
from .libreoffice_view import DOC_SUFFIXES
# ── VS-Code-Dark+-ish token palette ────────────────────────────────────────
class FolderTab(WorkspaceFileTreeMixin, DocumentPreviewMixin, AiFileEditorPanelMixin,
AiEditRunnerMixin, AiOutputWriterMixin, ImageModelPickerMixin,
QWidget):
"""Two-pane file explorer: directory tree + view/edit pane."""
status_message = Signal(str)
def __init__(self, ctx: AppContext, cowork=None):
super().__init__()
self.ctx = ctx
self._cowork = cowork # shared Cowork tab → reuse its conversation
self._ai_worker = None
self._ai_queue: list[str] = [] # instructions waiting for the current run
self._img_scan_worker = None # background scan for image models (all providers)
self._all_image_models: list = [] # [(provider_key, model)] found across ALL providers
self._ai_models_provider = "" # which provider the AI-edit model list was fetched for
self._edit_kind: Optional[str] = None # None | "html" | "pptx" (what the editor holds)
self._current_file: Optional[str] = None
self._root = str(ctx.config.cowork_output_dir())
self._pdf_view = None # lazy QtPdf view for office/pdf rendering
self._pdf_doc = None
self._pdf_tmp: Optional[str] = None
self._pdf_cache: dict = {} # (path, mtime) → converted .pdf path
self._convert_worker = None
self._xlsx_view = None # lazy QTabWidget table view for spreadsheets
root = QVBoxLayout(self)
# The path IS the title of this screen, so it is written as one rather
# than shown in a read-only text box that looks editable and costs a
# whole row of its own. Full path on hover; the button still opens the
# folder picker.
bar = QHBoxLayout()
self.path_lbl = QLabel(self._root)
self.path_lbl.setObjectName("folderTitle")
self.path_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse)
self.path_lbl.setToolTip(self._root)
self._open_btn = QPushButton()
self._open_btn.setIcon(icon("folder"))
self._open_btn.setObjectName("primary")
self._open_btn.clicked.connect(self._pick_root)
bar.addWidget(self.path_lbl, 1)
bar.addWidget(self._open_btn)
root.addLayout(bar)
split = QSplitter(Qt.Horizontal)
# ---- left: directory tree ------------------------------------------
self.model = QFileSystemModel()
self.model.setRootPath(self._root)
self.tree = QTreeView()
self.tree.setModel(self.model)
self.tree.setRootIndex(self.model.index(self._root))
for col in (1, 2, 3): # hide Size / Type / Date-modified columns
self.tree.hideColumn(col)
self.tree.setHeaderHidden(True)
self.tree.clicked.connect(self._on_tree_clicked)
split.addWidget(self.tree)
# ---- right: view / edit pane ---------------------------------------
right = QWidget()
rl = QVBoxLayout(right)
rl.setContentsMargins(0, 0, 0, 0)
hdr = QHBoxLayout()
self.file_label = QLabel("")
self.file_label.setStyleSheet("font-weight:600;")
self.file_label.setWordWrap(True)
hdr.addWidget(self.file_label, 1)
self.mode_btn = QPushButton() # Preview⇄Edit toggle (HTML / PPTX)
self.mode_btn.setCheckable(True)
self.mode_btn.clicked.connect(self._toggle_edit_mode)
self.mode_btn.setVisible(False)
hdr.addWidget(self.mode_btn)
self.ai_btn = QPushButton() # expand/collapse the AI-edit panel
self.ai_btn.setIcon(icon("sparkle"))
self.ai_btn.setCheckable(True)
self.ai_btn.clicked.connect(self._toggle_ai_panel)
hdr.addWidget(self.ai_btn)
self.save_btn = QPushButton()
self.save_btn.setIcon(icon("save"))
self.save_btn.setObjectName("primary")
self.save_btn.clicked.connect(self._save)
self.save_btn.setVisible(False)
hdr.addWidget(self.save_btn)
self.ext_btn = QPushButton()
self.ext_btn.setIcon(icon("upload"))
self.ext_btn.clicked.connect(self._open_external)
self.ext_btn.setVisible(False)
hdr.addWidget(self.ext_btn)
rl.addLayout(hdr)
self.stack = QStackedWidget()
self._placeholder = QLabel("")
self._placeholder.setObjectName("hint")
self._placeholder.setAlignment(Qt.AlignCenter)
self.stack.addWidget(self._placeholder) # 0
self.editor = CodeEditor() # 1
self.stack.addWidget(self.editor)
# HTML preview: a lightweight QTextBrowser fallback always exists; a real
# QWebEngineView is created LAZILY the first time an HTML file is
# previewed (so startup/tests never build WebEngine, and the onefile
# build — where WebEngine crashes — stays on the fallback).
self.web = QTextBrowser() # 2
self.web.setOpenExternalLinks(True)
self.stack.addWidget(self.web)
self._engine = None
self.doc_view = QTextBrowser() # 3
self.doc_view.setObjectName("docPreview")
self.stack.addWidget(self.doc_view)
self._img_scroll = QScrollArea() # 4
self._img_scroll.setWidgetResizable(True)
self._img_label = QLabel("")
self._img_label.setAlignment(Qt.AlignCenter)
self._img_scroll.setWidget(self._img_label)
self.stack.addWidget(self._img_scroll)
# Preview/editor on the left, a collapsible AI-edit panel on the right.
content_split = QSplitter(Qt.Horizontal)
content_split.addWidget(self.stack)
content_split.addWidget(self._build_ai_panel())
content_split.setStretchFactor(0, 1)
content_split.setStretchFactor(1, 0)
content_split.setSizes([700, 320])
self._content_split = content_split
self._ai_panel.setVisible(False) # default collapsed
rl.addWidget(content_split, 1)
split.addWidget(right)
split.setStretchFactor(0, 0)
split.setStretchFactor(1, 1)
split.setSizes([300, 800])
root.addWidget(split, 1)
# Terminal CLI below the file view — collapsible, default collapsed;
# opening it points the shell at the current workspace folder.
from .terminal_panel import TerminalPanel
self.terminal = TerminalPanel()
self.terminal.set_cwd(self._root)
self.terminal.expanded.connect(lambda: self.terminal.set_cwd(self._root))
root.addWidget(self.terminal)
on_language_changed(self._retranslate)
self._retranslate()
# ---- public API ---------------------------------------------------------
# ---- tree selection ------------------------------------------------------
# ---- open a file the right way -------------------------------------------
# ---- save / external -----------------------------------------------------
# ---- AI edit panel -------------------------------------------------------
_IMAGE_WORDS = ("image", "picture", "photo", "illustration", "icon", "logo", "diagram",
"ảnh", "hình", "minh họa", "biểu tượng", "画像", "イラスト")
# ---- phase 1: plan -------------------------------------------------------
# ---- token / cost accounting for AI-edit (like Cowork's per-message footer) --
# ---- phase 2: execute (edit the file) ------------------------------------
# ---- i18n ----------------------------------------------------------------
def _retranslate_mode_btn(self) -> None:
# Button label shows the action it performs: in Preview → "Edit"; in Edit → "Preview".
self.mode_btn.setText(tr("folder.edit") if not self.mode_btn.isChecked()
else tr("folder.preview"))
def _retranslate(self) -> None:
# The label always shows a real path, so the placeholder became a
# tooltip hint on the button that changes it.
self._open_btn.setToolTip(tr("folder.path_placeholder"))
self._open_btn.setText(tr("folder.open_folder"))
self.save_btn.setText(tr("folder.save"))
self.ext_btn.setText(tr("folder.open_external"))
self.ai_btn.setText(tr("folder.ai_edit"))
self.ai_btn.setToolTip(tr("folder.ai_edit_tooltip"))
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"))
self._ai_model_lbl.setText(tr("folder.ai_model_label"))
if self.ai_model_combo.count() >= 1 and self.ai_model_combo.itemData(0) is None:
self.ai_model_combo.setItemText(0, tr("folder.ai_model_auto"))
self._ai_apply_btn.setText(tr("folder.ai_apply"))
self._ai_discard_btn.setText(tr("folder.ai_discard"))
if not self._current_file:
self._placeholder.setText(tr("folder.select_file"))
self._retranslate_mode_btn()
_PPTX_READY = None # cached: pptx-editing library available (after auto-install)