"""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)