feat(R08): split ScheduleTaskTab, FolderTab, DashboardTab, StructureGraphView
Team Hoa, EPIC R08 (UI/Application Separation) - Team Hoa scope only
(R08-T11 -> T14; R08-T01->T10 belong to Team Duy/Team Nam).
- R08-T11: ui/schedule_task_tab.py (795 lines) -> presentation/scheduling/
{kanban_board_widget,calendar_view_widget,ai_task_creator_dialog,
ai_task_import_dialog,run_history_dialog}.py + schedule_task_tab.py
shell. Kanban CRUD/drag-drop now goes through
application/scheduling/task_application_service.py (R07-T04) instead of
~30 lines of inline if/elif per drag target.
- R08-T12: ui/folder_tab.py (1587 lines, the largest of the four) ->
presentation/folder/{workspace_file_tree,document_preview_manager,
code_editor,office_document_renderer,ai_file_editor_dialog,
ai_edit_model_resolver,ai_edit_pipeline}.py + folder_tab.py shell.
Closes the R06-T05 loop: FileWorkspaceService existed since R06 with
zero production call sites (confirmed by grep); every plain-text write
(save/create/write_content) now goes through it, gaining path
containment and a Python-syntax warning the original code never had.
Pure helpers (_read_text, _is_probably_text, _pptx_available,
_split_code_block, _parse_ai_output) moved to
application/workspaces/{file_preview_helpers,ai_edit_output}.py.
- R08-T13: ui/dashboard_tab.py (437 lines) -> presentation/dashboard/
{token_usage_card_widget,usage_chart_widget,habits_widget}.py +
dashboard_tab.py shell, backed by a new
application/monitoring/dashboard_query_service.py (pricing/period/
summary queries the three widgets used to each recompute separately).
Directory-ownership note left in the checklist for Team Nam.
- R08-T14: ui/structure_graph_view.py (1035 lines) ->
presentation/graph/{graph_scene_items,graph_renderer,
graph_messages_view,graph_qa_widget}.py + structure_graph_view.py
shell. Extraction helpers (_pdf_to_markdown, _extract_file_contents)
moved to application/workspaces/graph_index_service.py (pure Python).
Renderer and Q&A panel talk only through signals
(node_selected/graph_rendered/raw_json_ready/project_changed) - neither
imports the other.
- presentation/shared/web_engine_support.py: HAS_WEB_ENGINE, previously
duplicated (folder_tab imported it FROM structure_graph_view.py) - now
one shared flag instead of one screen importing another screen's module.
All four old ui/*.py files deleted; app.py and ui/workspace_tab.py updated
to the new import paths (each god-file only had 1-2 real construction
sites, so import sites were updated directly rather than kept as a
strangler-fig shim - unlike core/tools.py at R05, which had dozens).
pytest: 377 pass (+94 vs the R07 baseline of 328; same 4 pre-existing
failures as the R05/R06 baseline, unrelated to this work).
scripts/check_imports.py: PASS. python -c "import cowork_local.app": OK.
Every new file < 400 lines (largest: graph_renderer.py, 391).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
"""DocumentPreviewManager — the view/edit pane of the Folder Explorer
|
||||
(R08-T12, extracted from ``ui/folder_tab.py::FolderTab``, lines 295-373/
|
||||
410-449/649-699 of the original 1587-line file: the preview
|
||||
``QStackedWidget`` + open/save/create/external dispatch. HTML/PPTX/Excel/
|
||||
PDF/office rendering lives in ``office_document_renderer.py``; the code
|
||||
editor widget lives in ``code_editor.py`` — both split out to keep this file
|
||||
under the 400-line cap.
|
||||
|
||||
**Closes the R06-T05 loop**: ``application/workspaces/file_workspace_service.
|
||||
py::FileWorkspaceService`` existed since R06 but had zero production call
|
||||
sites (confirmed by grep before this task — ``ui/folder_tab.py`` wrote files
|
||||
with raw ``Path.write_text`` instead). Every plain-text write this class does
|
||||
(``save``, ``create_new_file``, ``write_content``) now goes through it —
|
||||
same path-containment check, same auto ``mkdir``, and (new, from
|
||||
``infrastructure/filesystem/file_tools.py::write_file``) a Python-syntax
|
||||
warning on a bad ``.py`` write, which the original code never had. A ``.pptx``
|
||||
save still goes through ``core/pptx_edit.py`` directly — that's a binary
|
||||
package build, not a text write, and ``FileWorkspaceService`` has no opinion
|
||||
on it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QLabel, QPushButton, QScrollArea, QStackedWidget,
|
||||
QTextBrowser, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.application.workspaces import FileWorkspaceService
|
||||
from cowork_local.application.workspaces.file_preview_helpers import (
|
||||
is_probably_text, pptx_available, read_text,
|
||||
)
|
||||
from cowork_local.domain.workspaces.workspace_session import WorkspaceSession
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.presentation.folder.code_editor import CodeEditor
|
||||
from cowork_local.presentation.folder.office_document_renderer import OfficeDocumentRenderer
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.libreoffice_view import DOC_SUFFIXES
|
||||
|
||||
_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
|
||||
|
||||
|
||||
class DocumentPreviewManager(QWidget):
|
||||
"""View/edit pane: header (file name, Preview⇄Edit toggle, Save, Open
|
||||
externally) above a ``QStackedWidget`` that renders whichever preview a
|
||||
file's suffix calls for."""
|
||||
|
||||
status_message = Signal(str)
|
||||
ai_reset_requested = Signal() # a DIFFERENT file was opened by the user
|
||||
|
||||
def __init__(self, root: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._root = root
|
||||
self._file_service = FileWorkspaceService(WorkspaceSession.unscoped(Path(root)))
|
||||
self._office = OfficeDocumentRenderer(self)
|
||||
self._edit_kind: Optional[str] = None # None | "html" | "pptx"
|
||||
self._current_file: Optional[str] = None
|
||||
|
||||
rl = QVBoxLayout(self)
|
||||
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._office.toggle_edit_mode)
|
||||
self.mode_btn.setVisible(False)
|
||||
hdr.addWidget(self.mode_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)
|
||||
# Exposed so the shell can insert its own AI-panel toggle button into
|
||||
# this same header row (between mode_btn and save_btn, matching the
|
||||
# original single-class layout) without this class knowing the AI
|
||||
# panel exists.
|
||||
self.header_layout = 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)
|
||||
|
||||
self.web = QTextBrowser() # 2
|
||||
self.web.setOpenExternalLinks(True)
|
||||
self.stack.addWidget(self.web)
|
||||
|
||||
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)
|
||||
|
||||
rl.addWidget(self.stack, 1)
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.save_btn.setText(tr("folder.save"))
|
||||
self.ext_btn.setText(tr("folder.open_external"))
|
||||
if not self._current_file:
|
||||
self._placeholder.setText(tr("folder.select_file"))
|
||||
self._retranslate_mode_btn()
|
||||
|
||||
def _retranslate_mode_btn(self) -> None:
|
||||
self.mode_btn.setText(tr("folder.edit") if not self.mode_btn.isChecked()
|
||||
else tr("folder.preview"))
|
||||
|
||||
# ---- public API used by the shell / AI panel --------------------------- #
|
||||
@property
|
||||
def current_file(self) -> Optional[str]:
|
||||
return self._current_file
|
||||
|
||||
@property
|
||||
def edit_kind(self) -> Optional[str]:
|
||||
return self._edit_kind
|
||||
|
||||
@property
|
||||
def root(self) -> str:
|
||||
return self._root
|
||||
|
||||
def set_root(self, root: str) -> None:
|
||||
self._root = root
|
||||
self._file_service = FileWorkspaceService(WorkspaceSession.unscoped(Path(root)))
|
||||
|
||||
def open_file(self, path: str, reset_ai: bool = True) -> None:
|
||||
# Switching to a DIFFERENT file starts a fresh AI-edit conversation
|
||||
# (reset_ai=False when the AI itself just CREATED this file — keep
|
||||
# that chat). Whether/how to reset is the AI panel's own business —
|
||||
# this class only announces that a genuine file switch happened.
|
||||
if reset_ai and path != self._current_file:
|
||||
self.ai_reset_requested.emit()
|
||||
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._office.show_html(path, mode_preview=True)
|
||||
elif suffix in _PPTX_SUFFIXES and pptx_available():
|
||||
self._office.show_pptx(path, mode_preview=True)
|
||||
elif suffix in _EXCEL_SUFFIXES:
|
||||
self._office.show_excel(path)
|
||||
elif suffix in DOC_SUFFIXES:
|
||||
self._office.show_document(path)
|
||||
elif size > _MAX_EDIT_BYTES or not is_probably_text(path):
|
||||
self._show_binary(path)
|
||||
else:
|
||||
self._show_code(path)
|
||||
|
||||
def ensure_editable_for_ai(self) -> bool:
|
||||
"""Make the current file editable in the code editor (switching an
|
||||
HTML preview to edit, or loading a text file). Returns False when
|
||||
there's no file open or it isn't a text/code file."""
|
||||
path = self._current_file
|
||||
if not path or not os.path.isfile(path):
|
||||
return False
|
||||
suffix = Path(path).suffix.lower()
|
||||
if suffix in _HTML_SUFFIXES:
|
||||
self._office.show_html(path, mode_preview=False)
|
||||
return True
|
||||
if suffix in _PPTX_SUFFIXES and pptx_available():
|
||||
self._office.show_pptx(path, mode_preview=False)
|
||||
return True
|
||||
if suffix in DOC_SUFFIXES or suffix in _IMAGE_SUFFIXES:
|
||||
return False
|
||||
if is_probably_text(path):
|
||||
self._show_code(path)
|
||||
return True
|
||||
return False
|
||||
|
||||
def save(self) -> None:
|
||||
if not self._current_file:
|
||||
return
|
||||
try:
|
||||
if self._edit_kind == "pptx":
|
||||
if not self._office.write_pptx(self.editor.toPlainText()):
|
||||
return
|
||||
else:
|
||||
self._write_plain_text(self._current_file, self.editor.toPlainText())
|
||||
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_content(self, content: str, skip_image_confirm: bool = False) -> None:
|
||||
"""Persist AI-confirmed content to disk AND refresh the preview.
|
||||
pptx text is written back into the deck (no PowerPoint window)."""
|
||||
if not self._current_file:
|
||||
return
|
||||
try:
|
||||
if self._edit_kind == "pptx":
|
||||
if not self._office.write_pptx(content, skip_confirm=skip_image_confirm):
|
||||
return
|
||||
else:
|
||||
self._write_plain_text(self._current_file, content)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.status_message.emit(tr("folder.save_error", err=str(exc)))
|
||||
return
|
||||
suffix = Path(self._current_file).suffix.lower()
|
||||
if suffix in _HTML_SUFFIXES:
|
||||
self._office.show_html(self._current_file, mode_preview=True)
|
||||
elif suffix in _PPTX_SUFFIXES:
|
||||
self._office.show_pptx(self._current_file, mode_preview=True)
|
||||
|
||||
def create_new_file(self, target: str, content: str) -> Optional[str]:
|
||||
"""Create ``target`` (relative to the folder root) with ``content``
|
||||
and open it — like Cowork's save_file. Refuses paths escaping the
|
||||
root (enforced by ``FileWorkspaceService``/``WorkspaceSession``)."""
|
||||
root = os.path.normpath(self._root)
|
||||
dest = target if os.path.isabs(target) else os.path.join(root, target)
|
||||
dest = os.path.normpath(dest)
|
||||
try:
|
||||
if Path(dest).suffix.lower() in _PPTX_SUFFIXES and pptx_available():
|
||||
# A .pptx is a binary package — build a real deck from the
|
||||
# marker text (writing text straight to .pptx would corrupt it).
|
||||
from cowork_local.core import pptx_edit
|
||||
os.makedirs(os.path.dirname(dest) or root, exist_ok=True)
|
||||
pptx_edit.create_pptx_from_text(dest, content)
|
||||
else:
|
||||
self._write_plain_text(dest, content)
|
||||
except Exception as exc: # noqa: BLE001 - OS error, containment error, or pptx build failure
|
||||
self.status_message.emit(tr("folder.save_error", err=str(exc)))
|
||||
return None
|
||||
self.open_file(dest, reset_ai=False) # show the new file; keep the AI chat
|
||||
return dest
|
||||
|
||||
def open_external(self) -> None:
|
||||
if self._current_file:
|
||||
from cowork_local.ui.osutil import open_location
|
||||
open_location(self._current_file)
|
||||
|
||||
# ---- writes ------------------------------------------------------------- #
|
||||
def _write_plain_text(self, path: str, content: str) -> None:
|
||||
"""Write ``content`` to ``path`` (must resolve inside the current
|
||||
root) via ``FileWorkspaceService`` — same containment check, ``mkdir``
|
||||
and Python-syntax warning the agent's own ``write_file`` tool gets."""
|
||||
rel = os.path.relpath(path, self._root)
|
||||
result = self._file_service.write_file(rel, content)
|
||||
if not result.get("ok"):
|
||||
raise OSError(result.get("output") or "write failed")
|
||||
|
||||
# ---- simple renderers (HTML/PPTX/Excel/PDF/office live in
|
||||
# office_document_renderer.py) --------------------------------------------- #
|
||||
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_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)
|
||||
|
||||
|
||||
__all__ = ["DocumentPreviewManager"]
|
||||
Reference in New Issue
Block a user