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>
309 lines
12 KiB
Python
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)
|
|
|