Feature/delta team/epic r04 (#7)
CI / test (push) Canceled after 0s

## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
This commit was merged in pull request #7.
This commit is contained in:
2026-08-31 05:15:13 +00:00
committed by gitea-admin
co-authored by anhtnm1 huongltt35 Nam Pham Dinh Thanh vudt15 Hiep Ha Van lamhv7
parent 86c27e2e79
commit f9f6bc01fd
496 changed files with 68421 additions and 19688 deletions
+4
View File
@@ -0,0 +1,4 @@
"""Folder Explorer screen, split into single-responsibility widgets
(R08-T12): ``workspace_file_tree``, ``document_preview_manager``,
``ai_edit_model_resolver``, ``ai_file_editor_dialog``, assembled by the
``folder_tab`` shell."""
@@ -0,0 +1,295 @@
"""AiEditModelResolver — model picker + Auto Model Routing + image-model
discovery for the AI-Edit panel (R08-T12, extracted from
``ui/folder_tab.py::FolderTab``, lines 802-1036/911-961 of the original
1587-line file: ``refresh_ai_models``, ``_scan_all_image_models``,
``_ai_provider``, ``_ai_apply_routing``, ``_confirm_routing_switch``,
``_ai_image_model``, ``_maybe_suggest_image_model``,
``_suggest_cross_provider_image``).
A plain (non-Qt-widget) helper composed BY
``ai_file_editor_dialog.py::AiFileEditorDialog`` — this is genuinely a
distinct concern (which provider/model answers THIS run) from the panel's
send/plan/edit orchestration, and splitting it out is also what keeps
``ai_file_editor_dialog.py`` under the 400-line cap.
"""
from __future__ import annotations
from typing import Any, List, Optional, Tuple
from cowork_local.core.worker import AgentWorker
from cowork_local.i18n import tr
class AiEditModelResolver:
"""Owns the AI-edit model combo's contents and every "which provider/
model should THIS run use" decision — independent of the Cowork/Settings
agent, exactly like the original panel's own picker was.
Args:
ctx: ``AppContext``.
model_combo: the ``QComboBox`` populated by :meth:`refresh`.
on_status: ``(text) -> None`` — posts a status line into the AI
chat (production passes ``ai_chat.add_status``).
confirm_switch: ``(self, decision, timeout) -> bool`` — the Qt
confirm dialog for Manual routing mode (kept as a callback so
this class never imports a dialog itself).
"""
def __init__(self, ctx, model_combo, on_status, confirm_switch) -> None:
"""Bộ chọn model của panel AI-Edit.
``on_status`` và ``confirm_switch`` được tiêm vào để lớp này không tự mở hộp
thoại — nhờ vậy test chạy được nó mà không cần giao diện.
"""
self.ctx = ctx
self._combo = model_combo
self._on_status = on_status
self._confirm_switch = confirm_switch
self._models: List[str] = []
self._models_provider = ""
self._all_image_models: List[Tuple[str, str]] = [] # [(provider_key, model)]
self._img_scan_worker = None
self._pending_img_suggest = False
self._routed_provider: Optional[str] = None
self._routed_model: Optional[str] = None
@property
def models(self) -> List[str]:
"""Danh sách model đã nạp cho provider hiện tại."""
return self._models
@property
def models_provider(self) -> str:
"""Provider mà danh sách model đang thuộc về; '' nếu chưa nạp lần nào."""
return self._models_provider
@property
def routed_provider(self) -> Optional[str]:
"""The provider :meth:`apply_routing` switched to for the current
run, or ``None`` when it didn't switch (routing off/declined)."""
return self._routed_provider
@property
def routed_model(self) -> Optional[str]:
"""Model do định tuyến tự động chọn cho lượt này; ``None`` nếu dùng model người dùng chọn."""
return self._routed_model
def should_refresh(self) -> bool:
"""True on first open, or when the active provider changed since
the model list was last loaded — a stale list would resolve a pick
to the wrong/default model at the new endpoint."""
return self._combo.count() <= 1 or self._models_provider != self.ctx.config.active_provider
def refresh(self) -> None:
"""Fetch the active provider's model list (background) into the
picker. Also proactively scans ALL providers for image-capable
models so a suggestion is ready the moment one is needed."""
name = self.ctx.config.active_provider
setting_model = self.ctx.config.provider_conf(name).get("model", "")
def job(worker):
"""Chạy nền: hỏi provider danh sách model, lỗi thì trả về list rỗng."""
prov = self.ctx.build_provider_for(name)
try:
models = list(getattr(prov, "list_models", lambda: [])() or [])
except Exception: # noqa: BLE001
models = []
return {"models": models}
def done(res):
"""Đổ danh sách vào ô chọn, giữ nguyên model đang chọn.
Luôn đưa model cấu hình trong Settings lên đầu, kể cả khi provider không
liệt kê được — để ô chọn không bao giờ chỉ có mỗi "(tự động)".
"""
fetched = list(res.get("models", []))
# Always offer the Settings-configured model as an explicit
# choice, even when the provider can't list models.
self._models = list(dict.fromkeys(
([setting_model] if setting_model else []) + [m for m in fetched if m]))
self._models_provider = name
cur = self._combo.currentData()
self._combo.blockSignals(True)
self._combo.clear()
self._combo.addItem(tr("folder.ai_model_auto"), None)
for m in self._models:
self._combo.addItem(m, m)
idx = self._combo.findData(cur)
self._combo.setCurrentIndex(idx if idx >= 0 else 0)
self._combo.blockSignals(False)
w = AgentWorker(job)
w.finished_ok.connect(done)
self._models_worker = w
w.start()
self._scan_all_image_models()
def _scan_all_image_models(self, then_suggest: bool = False) -> None:
"""Dò khắp mọi provider đã cấu hình xem cái nào có model sinh ảnh.
Chạy sẵn ở nền để lúc cần gợi ý là có ngay. Đang dò dở mà bị gọi lại thì
chỉ ghi nhận yêu cầu gợi ý, không dò chồng lên nhau.
"""
if self._img_scan_worker is not None:
if then_suggest:
self._pending_img_suggest = True
return
providers = dict(self.ctx.config.data.get("providers", {}))
candidates = [k for k, c in providers.items()
if (c.get("base_url") or c.get("api_key"))]
def job(worker):
"""Chạy nền: duyệt từng provider, lọc ra model sinh ảnh được."""
from cowork_local.core import image_gen
found = []
for key in candidates:
try:
prov = self.ctx.build_provider_for(key)
models = list(getattr(prov, "list_models", lambda: [])() or [])
except Exception: # noqa: BLE001 - a broken provider must not block the scan
models = []
for m in models:
if image_gen.looks_like_image_model(m):
found.append((key, m))
return {"found": found}
def done(res):
"""Ghi lại kết quả dò; có yêu cầu gợi ý đang chờ thì trả lời luôn."""
self._img_scan_worker = None
self._all_image_models = list(res.get("found", []))
if self._pending_img_suggest:
self._pending_img_suggest = False
self._suggest_cross_provider_image()
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(self._on_image_scan_failed)
self._img_scan_worker = w
if then_suggest:
self._pending_img_suggest = True
w.start()
def _on_image_scan_failed(self, _err) -> None:
"""Dò model ảnh thất bại: chỉ xoá cờ đang chạy, không báo lỗi ra màn hình —
đây là việc chạy ngầm mà người dùng không yêu cầu.
"""
self._img_scan_worker = None
def provider(self) -> Any:
"""Build a provider using the model chosen in the picker ('(auto)'
-> the active provider's default), or an Auto/Manual routing
override set by :meth:`apply_routing` for the current run."""
if self._routed_provider or self._routed_model:
provider = self._routed_provider or self.ctx.config.active_provider
return self.ctx.build_provider_for(provider, self._routed_model or None)
model = self._combo.currentData()
return self.ctx.build_provider_for(self.ctx.config.active_provider, model or None)
def apply_routing(self, instruction: str) -> None:
"""Auto Model Routing for the AI-Edit surface (always a CODING
task). Sets the routing override :meth:`provider` honours.
Never raises — a routing failure must never block an edit."""
self._routed_provider = None
self._routed_model = None
try:
from cowork_local.application.model_routing import (
RoutingRequest,
build_routing_application_service,
)
from cowork_local.core.routing.models import TaskType
cur_provider = self.ctx.config.active_provider
picked = self._combo.currentData()
cur_model = picked or self.ctx.config.provider_conf(cur_provider).get("model", "")
outcome = build_routing_application_service(self.ctx).resolve(
RoutingRequest(
surface="ai_edit",
prompt=instruction,
current_provider=cur_provider,
current_model=cur_model,
# An edit instruction is never a QA question, so the task
# type is pinned rather than classified from the prompt.
task_type=TaskType.CODING,
),
confirm=self._confirm_switch,
)
if not outcome.switched:
return
self._routed_provider = outcome.provider
self._routed_model = outcome.model
self._on_status(tr(
"routing.switched_notice",
model=outcome.model, task=outcome.task_type,
gain=f"{outcome.score_gain:.2f}"))
except Exception: # noqa: BLE001 — routing must never block an edit
self._routed_provider = None
self._routed_model = None
_IMAGE_WORDS = ("image", "picture", "photo", "illustration", "icon", "logo", "diagram",
"ảnh", "hình", "minh họa", "biểu tượng", "画像", "イラスト")
def maybe_suggest_image_model(self, instruction: str) -> None:
"""If the request looks image-related, suggest a suitable image
model BEFORE running — active provider first, then ALL providers."""
from cowork_local.core import image_gen
low = (instruction or "").lower()
if not any(w in low for w in self._IMAGE_WORDS):
return
picked = self._combo.currentData()
if picked and image_gen.looks_like_image_model(picked):
return
local = image_gen.suggest_image_model(self._models)
if local:
self._on_status(tr("folder.ai_image_suggest", model=local))
return
if self._all_image_models:
self._suggest_cross_provider_image()
elif self._img_scan_worker is not None:
self._pending_img_suggest = True
else:
self._scan_all_image_models(then_suggest=True)
def _suggest_cross_provider_image(self) -> None:
"""Gợi ý một model sinh ảnh, kể cả khi nó thuộc provider KHÁC provider đang chọn.
Đây là chỗ duy nhất trong màn Thư mục biết tới nhiều provider cùng lúc.
"""
from cowork_local.config import PROVIDER_LABELS
if not self._all_image_models:
picked = self._combo.currentData()
if picked:
self._on_status(tr("folder.ai_image_use_selected", model=picked))
else:
self._on_status(tr("folder.ai_image_none"))
return
seen, lines = set(), []
for key, model in self._all_image_models:
tag = (key, model)
if tag in seen:
continue
seen.add(tag)
lines.append(f"• {model} ({PROVIDER_LABELS.get(key, key)})")
if len(lines) >= 5:
break
self._on_status(tr("folder.ai_image_suggest_all") + "\n" + "\n".join(lines))
def image_model(self) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""Resolve ``(model, base_url, api_key)`` for image generation,
searching ALL providers — see the module docstring for priority
order (picked model if image-capable -> active provider's image
model -> any other provider's -> fall back to the picked model)."""
from cowork_local.core import image_gen
picked = self._combo.currentData()
if picked and image_gen.looks_like_image_model(picked):
return picked, None, None
local = image_gen.suggest_image_model(self._models)
if local:
return local, None, None
for key, model in self._all_image_models:
conf = self.ctx.config.provider_conf(key)
return model, (conf.get("base_url") or None), (conf.get("api_key") or None)
return (picked or None), None, None
__all__ = ["AiEditModelResolver"]
+400
View File
@@ -0,0 +1,400 @@
"""AiEditPipeline — the plan-then-edit-then-apply state machine behind the
AI-Edit panel (R08-T12, extracted from ``ui/folder_tab.py::FolderTab``,
lines 1068-1097/1119-1146/1147-1467 of the original 1587-line file:
``_ai_start`` through ``_ai_failed``, minus the queue/busy-badge bookkeeping
which stays on ``ai_file_editor_dialog.py::AiFileEditorDialog`` — see that
module's docstring for the split rationale).
A plain (non-Qt-widget) helper composed BY ``AiFileEditorDialog`` — same
composition-to-respect-the-400-line-cap pattern as
``office_document_renderer.py``. Talks to the file only through
``document_preview_manager.py``'s public API (``ensure_editable_for_ai``,
``write_content``, ``create_new_file``) — it never touches disk itself.
"""
from __future__ import annotations
import difflib
import os
from pathlib import Path
from typing import Optional
from cowork_local.application.workspaces.ai_edit_output import parse_ai_output
from cowork_local.core.worker import AgentWorker
from cowork_local.i18n import tr
from cowork_local.theme import current_palette
class AiEditPipeline:
"""Runs one instruction through PLAN -> EDIT -> (review) -> APPLY/DISCARD.
Args:
owner: the ``AiFileEditorDialog`` — supplies ``ai_chat``, ``preview``
(``DocumentPreviewManager``), ``resolver``
(``AiEditModelResolver``), ``ctx``, ``cowork_context()``, and is
told about status changes via ``on_busy_changed``/``on_flag_done``
so the panel's queue/badge bookkeeping stays in one place.
"""
def __init__(self, owner) -> None:
"""Vòng chạy một lượt sửa tệp bằng AI.
``pending`` giữ nội dung model đề xuất cho tới khi người dùng đồng ý: không
bao giờ ghi đè tệp trước khi có xác nhận.
"""
self._owner = owner
self.worker: Optional[AgentWorker] = None
self.pending: Optional[dict] = None # proposed content awaiting confirmation
self._ctx: dict = {}
self._prompt_usage: dict = {"in": 0, "out": 0, "cache": 0, "cost": 0.0}
self._running_file = ""
def start(self, instruction: str) -> None:
"""Begin processing one instruction. Assumes the pipeline is idle
(the panel's queue calls this when the previous run finishes)."""
o = self._owner
preview = o.preview
editable = preview.stack.currentWidget() is preview.editor
if not editable:
editable = preview.ensure_editable_for_ai()
o.resolver.maybe_suggest_image_model(instruction)
o.resolver.apply_routing(instruction) # may switch to the best coding model
has_file = editable and bool(preview.current_file)
self._running_file = Path(preview.current_file).name if has_file else tr("folder.ai_new_file")
o.set_busy(True)
o.status_message.emit(tr("folder.ai_running", name=self._running_file))
# Two phases so the PLAN is shown INLINE *before* the edit runs.
self._ctx = {
"filename": Path(preview.current_file).name if has_file else "",
"content": preview.editor.toPlainText() if has_file else "",
"convo": o.cowork_context(),
"instruction": instruction,
"provider": o.resolver.provider(),
"plan": "",
"edit_kind": preview.edit_kind,
}
self._prompt_usage = {"in": 0, "out": 0, "cache": 0, "cost": 0.0}
self._run_plan()
# ---- usage accounting (like Cowork's per-message footer) --------------- #
def _add_usage(self, usage) -> None:
"""Cộng dồn token của một lượt gọi vào tổng của cả phiên sửa file."""
if not isinstance(usage, dict):
return
tot = self._prompt_usage
tot["in"] += int(usage.get("in", 0) or 0)
tot["out"] += int(usage.get("out", 0) or 0)
tot["cache"] += int(usage.get("cache", 0) or 0)
tot["cost"] += float(usage.get("cost_usd", 0.0) or 0.0)
def _show_usage(self, bubble) -> None:
"""Gắn dòng token/chi phí dưới bong bóng trả lời; chưa dùng token nào thì bỏ qua."""
tot = self._prompt_usage
if bubble is None or not (tot["in"] or tot["out"]):
return
from cowork_local.core import model_pricing as mp, usage_tracker as ut
pricing = {**ut.DEFAULT_PRICING, **(self._owner.ctx.config.data.get("usage") or {})}
line = (f"↓{mp.format_tokens(int(tot['in']))} ↑{mp.format_tokens(int(tot['out']))} "
f"▤{mp.format_tokens(int(tot['in'] + tot['out'] + tot['cache']))} "
f"{ut.format_cost(tot['cost'], pricing)}")
try:
bubble.add_usage(line)
except Exception: # noqa: BLE001 - a usage footer must never break the edit
pass
# ---- phase 1: plan ------------------------------------------------------- #
def _run_plan(self) -> None:
"""Bước 1 — nhờ model lập kế hoạch sửa trước khi động vào nội dung."""
o = self._owner
c = self._ctx
plan_bubble = o.ai_chat.add_plan(tr("folder.ai_planning"))
o.ai_chat.scroll_to_bottom()
def job(worker):
"""Chạy nền: gọi model sinh kế hoạch và đo token đã dùng."""
from cowork_local.core import usage_tracker as ut
from cowork_local.core.co4e_runner import _usage_delta
provider = c["provider"]
messages = [{"role": "system", "content":
"You are an AI file editor. Give a SHORT numbered plan (2-4 steps) for "
"the requested change. Plan ONLY — do NOT output any code."}]
if c["convo"]:
messages.append({"role": "system",
"content": "Context from the user's Cowork conversation:\n" + c["convo"]})
messages.append({"role": "user", "content":
f"File: {c['filename']}\n\nCurrent content:\n```\n{c['content']}\n```\n\n"
f"Request: {c['instruction']}"})
ut.set_context("folder", c.get("filename") or "AI edit")
ut.begin_accumulation(); base = ut.accumulated()
try:
r = provider.chat(messages, tools=None, cancel=worker.is_cancelled)
txt = r.get("content", "") if isinstance(r, dict) else str(r)
usage = _usage_delta(base, o.ctx.config)
finally:
ut.end_accumulation()
return {"plan": provider.strip_think(txt) or "", "usage": usage}
worker = AgentWorker(job)
worker.finished_ok.connect(lambda res, b=plan_bubble: self._plan_done(res, b))
worker.failed.connect(lambda err, b=plan_bubble: self._failed(err, b))
self.worker = worker
worker.start()
def _plan_done(self, result, plan_bubble) -> None:
"""Có kế hoạch: hiện ra rồi chuyển sang bước sửa thật."""
self._add_usage((result or {}).get("usage"))
plan = ((result or {}).get("plan") or "").strip()
self._ctx["plan"] = plan
plan_bubble.set_plain(plan or tr("folder.ai_empty"))
self._owner.ai_chat.scroll_to_bottom()
self._run_edit()
# ---- phase 2: execute (edit the file) ------------------------------------ #
def _run_edit(self) -> None:
"""Bước 2 — sinh nội dung mới, phát dần vào bong bóng trả lời."""
o = self._owner
c = self._ctx
bubble = o.ai_chat.add_assistant(tr("folder.ai_edit"))
o.ai_chat.scroll_to_bottom()
pptx_note = ("\nThis is a PPTX shown as marker blocks '### Slide N / Box M', where N is the "
"1-based SLIDE NUMBER and M the box on that slide. When the user refers to a "
"slide by number (e.g. 'edit slide 3'), change ONLY the blocks under '### Slide "
"3' and leave every other slide's block exactly as-is. Each block has fields "
"type/pos/size/font/text (and image for pictures). To change TEXT COLOUR or "
"FONT edit the `font:` line — e.g. `font: name=Arial size=28 bold=1 "
"color=FF0000`. Keep all block markers and structure.") if c["edit_kind"] == "pptx" else ""
_pptx_words = ("pptx", "powerpoint", "slide", "presentation", "deck",
"スライド", "プレゼン", "trình chiếu", "trinh chieu", "bài thuyết trình")
wants_new_pptx = (c["edit_kind"] != "pptx"
and any(w in c["instruction"].lower() for w in _pptx_words))
new_pptx_note = ("\nTo CREATE a PowerPoint, name it `FILE: <name>.pptx` and output the slides "
"as marker blocks — one block per shape:\n"
"### Slide 1 / Box 1\ntype: text\npos: 0.5, 0.4\nsize: 9.0, 1.2\n"
"font: name=Calibri size=32 bold=1 color=1F3864\ntext:\nTitle here\n\n"
"### Slide 1 / Box 2\ntype: text\npos: 0.5, 1.8\nsize: 9.0, 4.5\n"
"text:\nBullet one\nBullet two\n\n"
"Increment the Slide number for each new slide; pos/size are in inches; "
"font color is RRGGBB hex.") if wants_new_pptx else ""
imggen_note = ""
try:
from cowork_local.core import image_gen
if image_gen.is_configured(o.ctx.config):
imggen_note = ("\nYou can also GENERATE an illustration image: add a line "
"`IMAGE_GEN: <describe the image> => <relative/path.png>`. Use a "
"generated image e.g. as a new picture, or (for pptx) set a picture "
"box's `image:` field to that same path to insert it.")
except Exception: # noqa: BLE001
pass
def job(worker):
"""Chạy nền: gọi model sinh nội dung sửa, kèm ngữ cảnh tệp đang mở."""
provider = c["provider"]
open_note = (f"the currently-open file '{c['filename']}'" if c["filename"]
else "no file is open")
messages = [{"role": "system", "content":
"You are an AI file editor inside an app. Following the plan, output the "
"COMPLETE file content in ONE fenced code block (```), and nothing after "
"it. Preserve everything you were not asked to change.\n"
"If the request is to CREATE A NEW file (or a different file than the one "
"open), put a line `FILE: <relative/path/name.ext>` (relative to the "
"current folder) immediately before the code block. Omit FILE to edit the "
f"open file. Right now, {open_note}." + pptx_note + new_pptx_note + imggen_note}]
if c["convo"]:
messages.append({"role": "system",
"content": "Context from the user's Cowork conversation:\n" + c["convo"]})
if c["plan"]:
messages.append({"role": "system", "content": "Plan to follow:\n" + c["plan"]})
cur = (f"File: {c['filename']}\n\nCurrent content:\n```\n{c['content']}\n```\n\n"
if c["filename"] else "No file is currently open.\n\n")
messages.append({"role": "user", "content": cur + f"Request: {c['instruction']}"})
def on_text(piece: str) -> None:
"""Đẩy từng mẩu nội dung ra ngoài để giao diện vẽ dần."""
worker.emit_event({"type": "text", "delta": piece})
from cowork_local.core import usage_tracker as ut
from cowork_local.core.co4e_runner import _usage_delta
ut.set_context("folder", c.get("filename") or "AI edit")
ut.begin_accumulation(); base = ut.accumulated()
try:
r = provider.chat(messages, tools=None, on_text=on_text, cancel=worker.is_cancelled)
txt = r.get("content", "") if isinstance(r, dict) else str(r)
usage = _usage_delta(base, o.ctx.config)
finally:
ut.end_accumulation()
return {"text": provider.strip_think(txt) or "", "usage": usage}
worker = AgentWorker(job)
worker.event.connect(lambda ev, b=bubble: self._stream(ev, b))
worker.finished_ok.connect(lambda res, b=bubble: self._done(res, b))
worker.failed.connect(lambda err, b=bubble: self._failed(err, b))
self.worker = worker
worker.start()
def _stream(self, ev, bubble) -> None:
"""Vẽ từng mẩu nội dung đang phát dần và cuộn theo."""
if isinstance(ev, dict) and ev.get("type") == "text":
bubble.append_delta(ev.get("delta", ""))
self._owner.ai_chat.scroll_to_bottom()
def _done(self, result, bubble) -> None:
"""Sinh xong: tách phần mã khỏi phần giải thích và dựng bản xem trước.
KHÔNG bao giờ ghi thẳng ra tệp ở đây — chỉ ``_finalize_apply`` mới chạm đĩa,
và chỉ sau khi người dùng bấm Áp dụng.
"""
o = self._owner
self.worker = None
o.set_busy(False)
self._add_usage((result or {}).get("usage"))
self._show_usage(bubble)
text = ((result or {}).get("text") or "").strip()
target, new_content, summary, image_gens = parse_ai_output(text)
if new_content is None and not image_gens:
bubble.set_markdown(text or tr("folder.ai_empty"))
o.ai_chat.scroll_to_bottom()
o.flag_done()
return
create = bool(target) and (not o.preview.current_file
or Path(target).name != Path(o.preview.current_file).name)
self.pending = {"content": new_content, "target": target if create else None,
"image_gens": image_gens}
hint = tr("folder.ai_review_hint")
bubble.set_markdown((summary + "\n\n" if summary else "") + "_" + hint + "_")
if new_content is not None:
old = "" if create else o.preview.editor.toPlainText()
diff = "".join(difflib.unified_diff(
old.splitlines(keepends=True), new_content.splitlines(keepends=True),
fromfile=("(new file)" if create else "current"),
tofile=(target if create else "proposed"))) or "(no textual difference)"
title = tr("folder.ai_proposed_new", name=target) if create else tr("folder.ai_proposed")
o.ai_chat.add_diff(title, diff)
if image_gens:
listing = "\n".join(f"• {p} ({prompt[:60]})" for prompt, p in image_gens)
o.ai_chat.add_status(tr("folder.ai_image_plan") + "\n" + listing)
o.show_confirm_row(True)
o.ai_chat.scroll_to_bottom()
name = target if create else self._running_file
o.status_message.emit(tr("folder.ai_proposed_status", name=name))
o.set_review_status("● " + hint, current_palette().warning)
# ---- apply / discard ------------------------------------------------------ #
def apply(self) -> None:
"""Confirmed by the user. If the edit GENERATES images, ask the
image gate then generate them (off-thread) before finalising."""
if not self.pending:
return
p = self.pending
self.pending = None
self._owner.show_confirm_row(False)
if p.get("image_gens"):
from PySide6.QtWidgets import QMessageBox
if QMessageBox.question(self._owner, tr("folder.ai_image_confirm_title"),
tr("folder.ai_image_confirm_gen")) != QMessageBox.Yes:
self._owner.status_message.emit(tr("folder.ai_image_declined"))
return
self._generate_then_finalize(p)
return
self._finalize_apply(p)
def _generate_then_finalize(self, p: dict) -> None:
"""Lượt sửa có yêu cầu sinh ảnh: tạo ảnh trước rồi mới ghi nội dung.
Phải theo thứ tự đó vì nội dung có thể tham chiếu tới đường dẫn ảnh vừa tạo.
"""
o = self._owner
imgs = p.get("image_gens") or []
root = os.path.normpath(o.preview.root)
img_model, img_base, img_key = o.resolver.image_model()
o.set_busy(True)
o.status_message.emit(tr("folder.ai_generating"))
def job(worker):
"""Chạy nền: sinh lần lượt từng ảnh được yêu cầu."""
from cowork_local.core import image_gen
results = []
for prompt, rel in imgs:
dest = rel if os.path.isabs(rel) else os.path.join(root, rel)
dest = os.path.normpath(dest)
if os.path.commonpath([dest, root]) != root:
results.append((rel, False, "path escapes the folder"))
continue
try:
os.makedirs(os.path.dirname(dest) or root, exist_ok=True)
except OSError as exc:
results.append((rel, False, str(exc)))
continue
ok, msg = image_gen.generate_image(o.ctx.config, prompt, dest,
model=img_model, base_url=img_base, api_key=img_key)
results.append((dest, ok, msg))
return {"results": results}
worker = AgentWorker(job)
worker.finished_ok.connect(lambda res, pp=p: self._images_done(res, pp))
worker.failed.connect(lambda err, pp=p: self._images_done({"results": [], "err": err}, pp))
self.worker = worker
worker.start()
def _images_done(self, res: dict, p: dict) -> None:
"""Ảnh đã sinh xong: ghi nội dung ra đĩa."""
o = self._owner
self.worker = None
o.set_busy(False)
created = []
for dest, ok, msg in res.get("results", []):
if ok:
created.append(dest)
o.ai_chat.add_success("✓ " + tr("folder.ai_image_created", name=Path(dest).name))
else:
o.ai_chat.add_error(tr("folder.ai_image_failed", err=msg))
self._finalize_apply(p, images_done=True)
if p.get("content") is None and not p.get("target") and created:
o.preview.open_file(created[0], reset_ai=False)
def _finalize_apply(self, p: dict, images_done: bool = False) -> None:
"""Ghi nội dung đã được duyệt xuống đĩa và làm mới khung xem."""
o = self._owner
content = p.get("content")
target = p.get("target")
if content is None:
o.ai_chat.scroll_to_bottom()
o.flag_done()
o.status_message.emit(tr("folder.ai_done", name=self._running_file))
return
if target:
dest = o.preview.create_new_file(target, content)
if dest is None:
return
o.ai_chat.add_success("✓ " + tr("folder.ai_created", name=Path(dest).name))
o.status_message.emit(tr("folder.ai_created", name=Path(dest).name))
else:
o.preview.editor.setPlainText(content) # live update in the editor/preview
o.preview.write_content(content, skip_image_confirm=images_done)
o.ai_chat.add_success("✓ " + tr("folder.ai_applied"))
o.status_message.emit(tr("folder.ai_done", name=self._running_file))
o.ai_chat.scroll_to_bottom()
o.flag_done()
def discard(self) -> None:
"""Bỏ bản đề xuất đang chờ, không chạm vào tệp."""
o = self._owner
self.pending = None
o.show_confirm_row(False)
o.ai_chat.add_status(tr("folder.ai_discarded"))
o.ai_chat.scroll_to_bottom()
o.set_review_status("", None)
o.maybe_dequeue() # discarding resolves the gate → run the next queued edit
def _failed(self, err, bubble) -> None:
"""Lượt sửa lỗi: hiện lỗi trong bong bóng và mở khoá lại panel."""
o = self._owner
self.worker = None
bubble.set_markdown(tr("folder.ai_error", err=err))
o.set_busy(False)
o.status_message.emit(tr("folder.ai_error", err=err))
o.flag_done()
__all__ = ["AiEditPipeline"]
@@ -0,0 +1,246 @@
"""AiFileEditorDialog — the collapsible AI-edit panel of the Folder Explorer
(R08-T12, extracted from ``ui/folder_tab.py::FolderTab``, lines 701-800/
1039-1066/1099-1118/1468-1517 of the original 1587-line file:
``_build_ai_panel``, panel open/reset, the instruction queue, and the busy/
done status line).
Despite the name (matching ``docs/refactor/Feature_Architecture_Proposal.md``'s
R08-T12 file list), this is an inline collapsible ``QWidget`` panel, not a
modal ``QDialog`` — exactly like the original ``_ai_panel`` was.
Composes two helpers to stay under the 400-line cap:
``ai_edit_model_resolver.py::AiEditModelResolver`` (which provider/model
answers a run) and ``ai_edit_pipeline.py::AiEditPipeline`` (the actual
plan-then-edit-then-apply state machine). This class owns the widget itself,
the instruction queue, and the busy/done status line/badge — the parts that
needed to stay together because the queue decides when the pipeline's next
``start()`` call happens.
"""
from __future__ import annotations
from typing import List, Optional
from PySide6.QtWidgets import (
QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget,
)
from PySide6.QtCore import Signal
from cowork_local.i18n import on_language_changed, tr
from cowork_local.presentation.folder.ai_edit_model_resolver import AiEditModelResolver
from cowork_local.presentation.folder.ai_edit_pipeline import AiEditPipeline
from cowork_local.theme import current_palette
from cowork_local.ui.chat_view import ChatView
class AiFileEditorDialog(QWidget):
"""Collapsible panel: a Cowork-style inline chat timeline, this panel's
OWN model picker + routing toggle, an instruction box, and an Apply/
Discard confirmation bar for the proposed edit.
Args:
ctx: ``AppContext``.
preview: ``document_preview_manager.py::DocumentPreviewManager`` —
every read/write of the actual file content goes through it.
cowork: the shared Cowork tab (optional) — its recent messages are
included as background context for the edit.
"""
status_message = Signal(str)
badge_changed = Signal(str) # "" | " ⏳" | " ✓" — the shell mirrors this onto its toggle button
def __init__(self, ctx, preview, cowork=None, parent=None):
"""Hộp thoại sửa tệp bằng AI.
``resolver`` dựng sau, vì nó cần chính ô chọn model mà hàm này mới tạo.
"""
super().__init__(parent)
self.ctx = ctx
self.preview = preview
self._cowork = cowork
self._ai_queue: List[str] = []
self.pipeline = AiEditPipeline(self)
self.resolver: Optional[AiEditModelResolver] = None # built after ai_model_combo exists
preview.ai_reset_requested.connect(self.reset_conversation)
preview.status_message.connect(self.status_message.emit)
v = QVBoxLayout(self)
v.setContentsMargins(6, 0, 0, 0)
v.setSpacing(4)
title_row = QHBoxLayout()
self._ai_title = QLabel(tr("folder.ai_edit"))
self._ai_title.setStyleSheet("font-weight:600;")
title_row.addWidget(self._ai_title)
title_row.addStretch(1)
self._ai_status = QLabel("")
self._ai_status.setObjectName("hint")
title_row.addWidget(self._ai_status)
v.addLayout(title_row)
self.ai_chat = ChatView()
v.addWidget(self.ai_chat, 1)
model_row = QHBoxLayout()
self._ai_model_lbl = QLabel(tr("folder.ai_model_label"))
self._ai_model_lbl.setObjectName("hint")
model_row.addWidget(self._ai_model_lbl)
self.ai_model_combo = QComboBox()
self.ai_model_combo.addItem(tr("folder.ai_model_auto"), None)
model_row.addWidget(self.ai_model_combo, 1)
from cowork_local.ui.routing_toggle import RoutingToggle
self.ai_routing_toggle = RoutingToggle(self.ctx, "ai_edit")
model_row.addWidget(self.ai_routing_toggle)
v.addLayout(model_row)
self.resolver = AiEditModelResolver(
ctx, self.ai_model_combo, self.ai_chat.add_status, self._confirm_routing_switch)
row = QHBoxLayout()
self.ai_input = QLineEdit()
self.ai_input.setPlaceholderText(tr("folder.ai_placeholder"))
self.ai_input.returnPressed.connect(self._ai_send)
row.addWidget(self.ai_input, 1)
self.ai_send_btn = QPushButton(tr("folder.ai_send"))
self.ai_send_btn.setObjectName("primary")
self.ai_send_btn.clicked.connect(self._ai_send)
row.addWidget(self.ai_send_btn)
v.addLayout(row)
self._ai_confirm_row = QWidget()
cf = QHBoxLayout(self._ai_confirm_row)
cf.setContentsMargins(0, 0, 0, 0)
cf.addStretch(1)
self._ai_discard_btn = QPushButton(tr("folder.ai_discard"))
self._ai_discard_btn.clicked.connect(self.pipeline.discard)
cf.addWidget(self._ai_discard_btn)
self._ai_apply_btn = QPushButton(tr("folder.ai_apply"))
self._ai_apply_btn.setObjectName("primary")
self._ai_apply_btn.clicked.connect(self.pipeline.apply)
cf.addWidget(self._ai_apply_btn)
self._ai_confirm_row.setVisible(False)
v.addWidget(self._ai_confirm_row)
on_language_changed(self.retranslate)
self.retranslate()
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
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"))
# ---- called by the shell (header button, splitter owner) --------------- #
def on_opened(self) -> None:
"""The shell's AI toggle button was just checked ON."""
self.ai_input.setFocus()
if self.resolver.should_refresh():
self.resolver.refresh()
if self.pipeline.worker is None:
self.badge_changed.emit("")
self._ai_status.setText("")
def reset_conversation(self) -> None:
"""Clear the AI-edit chat so each file starts a clean conversation. A
run in progress (editing the previous file) is left untouched — the
reset applies the next time a file is opened while idle."""
if self.pipeline.worker is not None:
return
self.ai_chat.clear()
self.badge_changed.emit("")
self.pipeline.pending = None
self._ai_confirm_row.setVisible(False)
self._ai_status.setText("")
def cowork_context(self) -> str:
"""The whole Cowork conversation (recent turns) as background context."""
cw = self._cowork
msgs = getattr(cw, "messages", None) if cw is not None else None
if not msgs:
return ""
lines = [f"{m['role']}: {str(m['content'])[:1000]}"
for m in msgs if m.get("role") in ("user", "assistant") and m.get("content")]
return "\n".join(lines[-12:])
# ---- send / queue -------------------------------------------------------- #
def _ai_send(self) -> None:
"""Gửi một yêu cầu sửa; chưa chọn thư mục làm việc thì báo lỗi ngay."""
if not self.preview.root:
self.ai_chat.add_error(tr("folder.ai_no_file"))
return
instruction = self.ai_input.text().strip()
if not instruction:
return
self.ai_input.clear()
self.ai_chat.add_user(instruction)
# QUEUE: while a run is active OR a proposal is awaiting Apply/Discard,
# hold the new instruction and run it once the pipeline goes idle.
if self.pipeline.worker is not None or self.pipeline.pending is not None:
self._ai_queue.append(instruction)
self.ai_chat.add_status(tr("folder.ai_queued", n=len(self._ai_queue)))
self._update_queue_status()
return
self.pipeline.start(instruction)
def _update_queue_status(self) -> None:
"""Cập nhật dòng trạng thái theo số yêu cầu còn xếp hàng."""
n = len(self._ai_queue)
if n:
self._ai_status.setText("⏳ " + tr("folder.ai_status_running")
+ " · " + tr("folder.ai_queue_count", n=n))
self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
def maybe_dequeue(self) -> None:
"""When the pipeline is fully idle, start the next queued instruction."""
if self.pipeline.worker is not None or self.pipeline.pending is not None:
return
if not self._ai_queue:
return
nxt = self._ai_queue.pop(0)
self._update_queue_status()
self.pipeline.start(nxt)
# ---- pipeline callbacks (see ai_edit_pipeline.py) ------------------------- #
def set_busy(self, busy: bool) -> None:
"""Khoá/mở ô nhập và nút gửi trong lúc một lượt sửa đang chạy."""
self.ai_input.setEnabled(not busy)
self.ai_send_btn.setEnabled(not busy)
if busy:
self._ai_status.setText("⏳ " + tr("folder.ai_status_running"))
self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
self.badge_changed.emit(" ⏳") # visible even when collapsed
else:
self._ai_status.setText("")
self.badge_changed.emit("")
def flag_done(self) -> None:
"""After a background run, show a 'done' badge so the user notices
the result when they return to the tab; cleared on reopen. If more
instructions are queued, start the next one instead."""
if self.pipeline.worker is None and self.pipeline.pending is None and self._ai_queue:
self.maybe_dequeue()
return
self._ai_status.setText("✓ " + tr("folder.ai_status_done"))
self._ai_status.setStyleSheet(f"color:{current_palette().success};")
self.badge_changed.emit(" ✓")
def show_confirm_row(self, visible: bool) -> None:
"""Hiện/ẩn hàng nút Áp dụng · Huỷ cho bản đề xuất đang chờ."""
self._ai_confirm_row.setVisible(visible)
def set_review_status(self, text: str, color) -> None:
"""Đặt dòng trạng thái kèm màu (xanh khi xong, đỏ khi lỗi)."""
self._ai_status.setText(text)
if color:
self._ai_status.setStyleSheet(f"color:{color};")
def _confirm_routing_switch(self, decision, timeout: float) -> bool:
"""Manual mode: ask before moving this AI-Edit run to another model."""
from cowork_local.ui.routing_toggle import confirm_switch
return bool(confirm_switch(self, decision, timeout))
__all__ = ["AiFileEditorDialog"]
+220
View File
@@ -0,0 +1,220 @@
"""CodeEditor — the VS-Code-style code/text editor widget (R08-T12, split
out of ``document_preview_manager.py`` to keep that file under the 400-line
cap; originally ``ui/folder_tab.py``, lines 61-236 of the original
1587-line file: the Pygments token-colour helper, ``PygmentsHighlighter``,
``_LineNumbers``, ``CodeEditor``).
"""
from __future__ import annotations
from PySide6.QtCore import QRect, QSize, Qt, QTimer
from PySide6.QtGui import QColor, QFont, QPainter, QSyntaxHighlighter, QTextCharFormat
from PySide6.QtWidgets import QPlainTextEdit, QWidget
from cowork_local.theme import current_palette
_MAX_HIGHLIGHT_CHARS = 300_000 # skip colouring huge files (keeps typing snappy)
# ── VS-Code-Dark+-ish token palette ────────────────────────────────────────
def _fmt(color: str, *, italic: bool = False, bold: bool = False) -> QTextCharFormat:
"""Dựng một định dạng ký tự cho bộ tô màu cú pháp."""
f = QTextCharFormat()
f.setForeground(QColor(color))
if italic:
f.setFontItalic(True)
if bold:
f.setFontWeight(QFont.Bold)
return f
class PygmentsHighlighter(QSyntaxHighlighter):
"""Colour the whole document with Pygments and apply per-block. Re-lexes the
full text (debounced) so multi-line strings/comments colour correctly."""
def __init__(self, document):
"""Tô màu cú pháp bằng Pygments.
Tô lại sau một nhịp trễ (``QTimer`` chạy một lần) thay vì ngay mỗi phím: gõ
nhanh trong file lớn mà tô đồng bộ thì giao diện khựng.
"""
super().__init__(document)
from pygments.lexers.special import TextLexer
self._lexer = TextLexer(stripnl=False)
self._ranges: list[tuple[int, int, QTextCharFormat]] = []
self._rules = self._build_rules()
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.setInterval(250)
self._timer.timeout.connect(self._retokenize)
document.contentsChanged.connect(self._timer.start)
@staticmethod
def _build_rules():
"""Dựng bảng ánh xạ loại token của Pygments sang màu theo theme đang dùng."""
from pygments.token import (
Comment, Error, Keyword, Name, Number, Operator, Punctuation, String,
)
p = current_palette()
return [
(Comment, _fmt(p.code_comment, italic=True)),
(Keyword.Type, _fmt(p.code_type)),
(Keyword, _fmt(p.code_keyword)),
(Name.Function, _fmt(p.code_func)),
(Name.Class, _fmt(p.code_type)),
(Name.Decorator, _fmt(p.code_func)),
(Name.Builtin, _fmt(p.code_type)),
(Name.Tag, _fmt(p.code_keyword)),
(Name.Attribute, _fmt(p.code_attr)),
(String.Doc, _fmt(p.code_comment, italic=True)),
(String, _fmt(p.code_string)),
(Number, _fmt(p.code_number)),
(Operator, _fmt(p.code_fg)),
(Punctuation, _fmt(p.code_fg)),
(Error, _fmt(p.code_error)),
]
def set_filename(self, filename: str, text: str = "") -> None:
"""Chọn bộ phân tích cú pháp theo đuôi tệp; không nhận ra thì tắt tô màu."""
from pygments.lexers import get_lexer_for_filename, guess_lexer
from pygments.lexers.special import TextLexer
from pygments.util import ClassNotFound
try:
self._lexer = get_lexer_for_filename(filename, stripnl=False)
except ClassNotFound:
try:
self._lexer = guess_lexer(text) if text.strip() else TextLexer()
except ClassNotFound:
self._lexer = TextLexer(stripnl=False)
self._retokenize()
def _fmt_for(self, tok):
"""Định dạng của một token; leo dần lên token cha cho tới khi khớp một luật."""
for ttype, fmt in self._rules:
if tok in ttype:
return fmt
return None
def _retokenize(self) -> None:
"""Phân tích lại toàn bộ tài liệu và ghi nhớ định dạng cho từng dòng.
Pygments phân tích theo cả tệp chứ không theo từng dòng, nên không thể
tô đúng nếu chỉ nhìn một dòng — ví dụ chuỗi nhiều dòng hay khối chú thích.
"""
from pygments import lex
text = self.document().toPlainText()
self._ranges = []
if len(text) <= _MAX_HIGHLIGHT_CHARS:
pos = 0
for tok, val in lex(text, self._lexer):
fmt = self._fmt_for(tok)
if fmt is not None and val:
self._ranges.append((pos, pos + len(val), fmt))
pos += len(val)
self.rehighlight()
def highlightBlock(self, text: str) -> None: # noqa: N802 - Qt override
"""Áp định dạng đã tính sẵn cho một dòng. Qt gọi hàm này cho từng dòng hiện trên màn."""
if not self._ranges:
return
bstart = self.currentBlock().position()
bend = bstart + len(text)
for start, end, fmt in self._ranges:
if end <= bstart or start >= bend:
continue
s = max(start, bstart) - bstart
e = min(end, bend) - bstart
if e > s:
self.setFormat(s, e - s, fmt)
class _LineNumbers(QWidget):
"""Máng số dòng vẽ bên trái ô soạn thảo."""
def __init__(self, editor):
"""Dải số dòng bên trái ô soạn mã."""
super().__init__(editor)
self._editor = editor
def sizeHint(self) -> QSize:
"""Bề rộng máng số dòng, do ô soạn thảo tính theo số chữ số của dòng cuối."""
return QSize(self._editor.line_number_width(), 0)
def paintEvent(self, event): # noqa: N802
"""Nhờ ô soạn thảo vẽ — nó mới biết dòng nào đang hiện ở đâu."""
self._editor.paint_line_numbers(event)
class CodeEditor(QPlainTextEdit):
"""A dark, monospaced editor with a line-number gutter + Pygments colouring —
the Sublime/VS-Code look for viewing & editing source files."""
def __init__(self):
"""Ô soạn mã: phông đều, không tự xuống dòng, tab rộng 4 ký tự.
Không tự xuống dòng là cố ý — mã bị bẻ dòng thì lệch thụt đầu dòng và khó
đọc hơn là phải cuộn ngang.
"""
super().__init__()
self.setObjectName("codeEditor")
self.setLineWrapMode(QPlainTextEdit.NoWrap)
self.setTabStopDistance(4 * self.fontMetrics().horizontalAdvance(" "))
font = QFont("Consolas")
font.setStyleHint(QFont.Monospace)
font.setPointSize(10)
self.setFont(font)
self._gutter = _LineNumbers(self)
self.blockCountChanged.connect(lambda _=0: self._update_gutter_width())
self.updateRequest.connect(self._on_update_request)
self._highlighter = PygmentsHighlighter(self.document())
self._update_gutter_width()
def line_number_width(self) -> int:
"""Bề rộng cần cho máng số dòng, tính theo số chữ số của dòng cuối cùng."""
digits = max(2, len(str(max(1, self.blockCount()))))
return 12 + self.fontMetrics().horizontalAdvance("9") * digits
def _update_gutter_width(self) -> None:
"""Chừa lề trái đúng bằng bề rộng máng số dòng."""
self.setViewportMargins(self.line_number_width(), 0, 0, 0)
def _on_update_request(self, rect, dy: int) -> None:
"""Cuộn hoặc vẽ lại vùng nào thì máng số dòng cuộn/vẽ lại đúng vùng đó."""
if dy:
self._gutter.scroll(0, dy)
else:
self._gutter.update(0, rect.y(), self._gutter.width(), rect.height())
if rect.contains(self.viewport().rect()):
self._update_gutter_width()
def resizeEvent(self, event): # noqa: N802
"""Đổi kích thước thì đặt lại hình chữ nhật của máng số dòng."""
super().resizeEvent(event)
cr = self.contentsRect()
self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.line_number_width(), cr.height()))
def paint_line_numbers(self, event) -> None:
"""Vẽ số của các dòng đang hiện trên màn, bỏ qua dòng bị gập hoặc nằm ngoài khung."""
p = current_palette()
painter = QPainter(self._gutter)
painter.fillRect(event.rect(), QColor(p.code_gutter_bg))
block = self.firstVisibleBlock()
num = block.blockNumber()
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
bottom = top + self.blockBoundingRect(block).height()
painter.setPen(QColor(p.code_gutter_fg))
while block.isValid() and top <= event.rect().bottom():
if block.isVisible() and bottom >= event.rect().top():
painter.drawText(0, int(top), self._gutter.width() - 6,
self.fontMetrics().height(), Qt.AlignRight,
str(num + 1))
block = block.next()
top = bottom
bottom = top + self.blockBoundingRect(block).height()
num += 1
def load_file(self, path: str, text: str) -> None:
"""Nạp nội dung tệp vào ô soạn thảo và bật tô màu theo đuôi tệp."""
self.setPlainText(text)
self._highlighter.set_filename(path, text)
__all__ = ["CodeEditor", "PygmentsHighlighter"]
@@ -0,0 +1,327 @@
"""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):
"""Khung xem tài liệu.
Mọi lượt đọc tệp đi qua ``FileWorkspaceService`` nên không thoát ra ngoài
thư mục gốc, kể cả khi đường dẫn có ``..``.
"""
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:
"""Áp lại chữ theo ngôn ngữ đang chọn cho các nút và ô trống."""
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:
"""Nhãn nút chuyển chế độ đổi theo trạng thái: đang xem thì ghi "Sửa" và ngược lại."""
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]:
"""Đường dẫn tệp đang mở; ``None`` nếu chưa mở tệp nào."""
return self._current_file
@property
def edit_kind(self) -> Optional[str]:
"""Loại nội dung đang sửa ('pptx', 'html', 'text'…); ``None`` nếu đang chỉ xem."""
return self._edit_kind
@property
def root(self) -> str:
"""Thư mục gốc mà mọi thao tác ghi phải nằm bên trong."""
return self._root
def set_root(self, root: str) -> None:
"""Đổi thư mục gốc và dựng lại dịch vụ ghi tệp gắn với phạm vi mới."""
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.
"""Mở một tệp, tự chọn cách hiển thị theo đuôi và kích thước.
Thứ tự ưu tiên: ảnh → HTML → PowerPoint → Excel → tài liệu Office → nhị
phân (quá lớn hoặc không phải văn bản) → mã nguồn. Đổi sang tệp KHÁC thì
báo cho panel AI biết để nó bắt đầu hội thoại mới; ``reset_ai=False`` dùng
khi chính AI vừa tạo ra tệp này — giữ nguyên đoạn chat đang dở.
"""
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:
"""Lưu nội dung đang sửa xuống tệp; pptx ghi ngược vào bản trình chiếu."""
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:
"""Mở tệp đang xem bằng ứng dụng mặc định của hệ điều hành."""
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:
"""Hiện tệp trong ô soạn thảo có tô màu cú pháp, cho phép sửa và lưu."""
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:
"""Hiện ảnh; ảnh hỏng hoặc không đọc được thì rơi về khung nhị phân."""
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:
"""Hiện thông báo "tệp nhị phân" kèm nút mở bằng ứng dụng ngoài."""
self._placeholder.setText(tr("folder.binary_file"))
self.ext_btn.setVisible(True)
self.stack.setCurrentWidget(self._placeholder)
__all__ = ["DocumentPreviewManager"]
+129
View File
@@ -0,0 +1,129 @@
"""FolderTab shell (R08-T12) — assembles
``workspace_file_tree.py::WorkspaceFileTree``,
``document_preview_manager.py::DocumentPreviewManager`` and
``ai_file_editor_dialog.py::AiFileEditorDialog`` behind the splitter/terminal
layout that used to be inline in ``ui/folder_tab.py::FolderTab.__init__``
(lines 238-384 of the original 1587-line file).
The AI-panel toggle button (``ai_btn``) lives here because it controls
things two different children own: the panel's own visibility AND the
content splitter's sizing — a genuine shell-level concern, not either
child's.
"""
from __future__ import annotations
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSplitter, QVBoxLayout, QWidget
from PySide6.QtCore import Qt, Signal
from cowork_local.i18n import on_language_changed, tr
from cowork_local.presentation.folder.ai_file_editor_dialog import AiFileEditorDialog
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree
from cowork_local.state import AppContext
from cowork_local.ui.icons import icon
class FolderTab(QWidget):
"""Two-pane file explorer: directory tree + view/edit pane (+ collapsible
AI-edit panel, + collapsible terminal)."""
status_message = Signal(str)
def __init__(self, ctx: AppContext, cowork=None):
"""Ghép ba phần của tab Thư mục: cây tệp, khung xem và terminal."""
super().__init__()
self.ctx = ctx
self._root = str(ctx.config.cowork_output_dir())
root_layout = QVBoxLayout(self)
split = QSplitter(Qt.Horizontal)
self.tree = WorkspaceFileTree(self._root)
split.addWidget(self.tree)
right = QWidget()
rl = QVBoxLayout(right)
rl.setContentsMargins(0, 0, 0, 0)
self.preview = DocumentPreviewManager(self._root)
self.ai_panel = AiFileEditorDialog(ctx, self.preview, cowork=cowork)
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)
# Same visual position as the original single-class header: between
# the Preview⇄Edit toggle and Save (file_label=0, mode_btn=1).
self.preview.header_layout.insertWidget(2, self.ai_btn)
self.ai_panel.badge_changed.connect(self._on_ai_badge_changed)
content_split = QSplitter(Qt.Horizontal)
content_split.addWidget(self.preview)
content_split.addWidget(self.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_layout.addWidget(split, 1)
# Terminal CLI below the file view — collapsible, default collapsed;
# opening it points the shell at the current workspace folder.
from cowork_local.ui.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_layout.addWidget(self.terminal)
self.tree.file_selected.connect(self.preview.open_file)
self.preview.status_message.connect(self.status_message.emit)
self.ai_panel.status_message.connect(self.status_message.emit)
on_language_changed(self._retranslate)
self._retranslate()
# ---- public API ---------------------------------------------------------
def set_root(self, path: str) -> None:
"""Đổi thư mục gốc của cả ba widget con.
``WorkspaceFileTree`` lặng lẽ từ chối đường dẫn không hợp lệ, nên chỉ lan
sang khung xem và terminal khi cây thật sự đã nhận.
"""
self.tree.set_root(path)
# WorkspaceFileTree silently no-ops on an invalid path (same guard
# the original single-class _root setter had) — mirror that here by
# only propagating when the tree actually accepted it.
if self.tree.root == path:
self._root = path
self.preview.set_root(path)
self.terminal.set_cwd(path)
def _toggle_ai_panel(self) -> None:
"""Gập/mở panel AI-Edit; mở ra thì báo cho panel biết để nó nạp model lần đầu."""
show = self.ai_btn.isChecked()
self.ai_panel.setVisible(show)
if show:
self._content_split.setSizes([700, 320])
self.ai_panel.on_opened()
def _on_ai_badge_changed(self, suffix: str) -> None:
"""Cập nhật huy hiệu trên nút AI-Edit (số việc đang chờ)."""
self.ai_btn.setText(tr("folder.ai_edit") + suffix)
def _retranslate(self) -> None:
"""Chuyển lệnh dịch lại xuống cho cả ba widget con."""
self.tree.retranslate()
self.preview.retranslate()
self.ai_panel.retranslate()
self.ai_btn.setText(tr("folder.ai_edit"))
self.ai_btn.setToolTip(tr("folder.ai_edit_tooltip"))
__all__ = ["FolderTab"]
@@ -0,0 +1,289 @@
"""OfficeDocumentRenderer — HTML/PPTX/Excel/PDF/office-doc preview for
``document_preview_manager.py`` (R08-T12, split out to keep that file under
the 400-line cap; originally ``ui/folder_tab.py``, lines 451-647/679-694 of
the original 1587-line file).
A plain (non-Qt-widget) helper composed BY a ``DocumentPreviewManager``
rather than a widget of its own: these renderers are tightly coupled to the
manager's shared ``QStackedWidget``/toolbar/editor — genuinely one screen's
internal state, not an independent concern — so this is a composition split
to respect the line-count cap, the same way
``presentation/scheduling/kanban_board_widget.py`` composes
``TaskApplicationService`` rather than owning that logic inline.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Optional
from PySide6.QtWidgets import QTabWidget, QTableWidget, QTableWidgetItem
from cowork_local.application.workspaces.file_preview_helpers import read_text
from cowork_local.core.worker import AgentWorker
from cowork_local.i18n import tr
from cowork_local.presentation.shared import HAS_WEB_ENGINE
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
class OfficeDocumentRenderer:
"""Renders HTML/PPTX/Excel/PDF/office docs into ``owner.stack``.
``owner`` is the ``DocumentPreviewManager`` — this class reaches into
``owner.stack``/``owner.editor``/``owner.mode_btn``/``owner.ext_btn``/
``owner.save_btn``/``owner.doc_view``/``owner.web`` because those widgets
are shared with the manager's simpler renderers (code/image/binary);
duplicating them here would mean two stacked widgets fighting over which
one is "the" preview.
"""
def __init__(self, owner) -> None:
"""Bộ hiện tài liệu Office.
Có cache PDF vì mỗi lần chuyển đổi phải gọi LibreOffice — xem lại cùng một
tệp mà chuyển lại từ đầu thì chờ vài giây mỗi lượt.
"""
self._owner = owner
self._engine = None
self._pdf_view = None
self._pdf_doc = None
self._pdf_tmp: Optional[str] = None
self._pdf_cache: dict = {}
self._convert_worker = None
self._xlsx_view = None
def show_html(self, path: str, mode_preview: bool) -> None:
"""Hiện tệp HTML: xem đã dựng hình hoặc sửa mã nguồn, tuỳ ``mode_preview``."""
o = self._owner
o._edit_kind = "html"
o.mode_btn.setVisible(True)
o.mode_btn.setChecked(not mode_preview) # checked = Edit
o._retranslate_mode_btn()
if mode_preview:
from PySide6.QtCore import QUrl
html = read_text(path)
engine = self._ensure_engine()
if engine is not None:
engine.setHtml(html, QUrl.fromLocalFile(path))
o.stack.setCurrentWidget(engine)
else:
o.web.setHtml(html)
o.stack.setCurrentWidget(o.web)
o.save_btn.setVisible(False)
else:
o._show_code(path)
def show_pptx(self, path: str, mode_preview: bool) -> None:
"""PPTX: Preview renders the slides (PDF via LibreOffice); Edit shows the
deck's text (marker-delimited per box) in the editor."""
o = self._owner
o._edit_kind = "pptx"
o.mode_btn.setVisible(True)
o.mode_btn.setChecked(not mode_preview) # checked = Edit
o._retranslate_mode_btn()
o.ext_btn.setVisible(True)
if mode_preview:
self.show_document(path) # PDF render of the slides
o.mode_btn.setVisible(True) # show_document doesn't touch it
else:
from cowork_local.core.pptx_edit import pptx_to_text
try:
text = pptx_to_text(path)
except Exception as exc: # noqa: BLE001
text = f"[could not read pptx text: {exc}]"
o.editor.setReadOnly(False)
o.editor.load_file(path + ".txt", text) # .txt → plain highlighting
o.save_btn.setVisible(True)
o.stack.setCurrentWidget(o.editor)
def _ensure_engine(self):
"""Create the QWebEngineView on first HTML preview (only when WebEngine
is safe to use); otherwise stay on the QTextBrowser fallback."""
if not HAS_WEB_ENGINE:
return None
if self._engine is None:
try:
from PySide6.QtWebEngineWidgets import QWebEngineView
self._engine = QWebEngineView()
self._owner.stack.addWidget(self._engine)
except Exception: # noqa: BLE001
self._engine = None
return self._engine
def toggle_edit_mode(self) -> None:
"""Lật giữa Xem và Sửa cho tệp đang mở (HTML và PowerPoint)."""
o = self._owner
if not o.current_file:
return
preview = not o.mode_btn.isChecked() # checked = Edit
if o._edit_kind == "pptx":
self.show_pptx(o.current_file, mode_preview=preview)
else:
self.show_html(o.current_file, mode_preview=preview)
def show_excel(self, path: str) -> None:
"""View a spreadsheet as a real TABLE (openpyxl) — one tab per sheet."""
o = self._owner
o.ext_btn.setVisible(True)
try:
from cowork_local.core.deps import ensure_module
ensure_module("openpyxl", "openpyxl")
from openpyxl import load_workbook
wb = load_workbook(path, read_only=True, data_only=True)
except Exception: # noqa: BLE001 - no openpyxl / unreadable → try PDF/text
self.show_document(path)
return
MAX_ROWS, MAX_COLS = 2000, 100
if self._xlsx_view is None:
self._xlsx_view = QTabWidget()
o.stack.addWidget(self._xlsx_view)
tabs = self._xlsx_view
while tabs.count():
w = tabs.widget(0); tabs.removeTab(0); w.deleteLater()
try:
for ws in wb.worksheets:
rows = list(ws.iter_rows(max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True))
ncols = max((len(r) for r in rows), default=0)
table = QTableWidget(len(rows), ncols)
table.setEditTriggers(QTableWidget.NoEditTriggers)
table.horizontalHeader().setVisible(False)
for r, row in enumerate(rows):
for c, val in enumerate(row):
if val is not None:
table.setItem(r, c, QTableWidgetItem(str(val)))
table.resizeColumnsToContents()
title = ws.title + (" (…)" if (ws.max_row or 0) > MAX_ROWS
or (ws.max_column or 0) > MAX_COLS else "")
tabs.addTab(table, title)
finally:
wb.close()
if tabs.count() == 0:
self.show_document(path)
return
o.stack.setCurrentWidget(tabs)
def show_document(self, path: str) -> None:
"""Office docs + PDF are RENDERED via QtPdf — LibreOffice converts
them to PDF first. Falls back to text extraction when QtPdf/
LibreOffice aren't available."""
o = self._owner
o.ext_btn.setVisible(True)
suffix = Path(path).suffix.lower()
if not HAS_PDF:
self.show_document_text(path)
return
if suffix == ".pdf":
self._render_pdf(path)
return
try:
mtime = os.path.getmtime(path)
except OSError:
mtime = 0
cached = self._pdf_cache.get((path, mtime))
if cached and os.path.exists(cached):
self._render_pdf(cached)
return
from cowork_local.core.doc_extract import convert_to_pdf, find_soffice
if not find_soffice() and os.name != "nt":
self.show_document_text(path)
return
o.doc_view.setPlainText(tr("folder.converting"))
o.stack.setCurrentWidget(o.doc_view)
if self._pdf_tmp is None:
import tempfile
self._pdf_tmp = tempfile.mkdtemp(prefix="cowork_folder_pdf_")
src, out_dir = path, self._pdf_tmp
def job(worker):
"""Chạy nền: chuyển tài liệu Office sang PDF bằng LibreOffice."""
return {"src": src, "mtime": mtime, "pdf": convert_to_pdf(src, out_dir)}
def done(result):
"""Hiện PDF vừa chuyển và ghi vào bộ nhớ đệm theo (đường dẫn, thời điểm sửa).
Bỏ kết quả nếu người dùng đã chuyển sang tệp khác trong lúc chờ chuyển đổi.
"""
if result.get("src") != o.current_file:
return # user moved on to another file
pdf = result.get("pdf")
if pdf:
self._pdf_cache[(result["src"], result["mtime"])] = pdf
self._render_pdf(pdf)
else:
self.show_document_text(src)
worker = AgentWorker(job)
worker.finished_ok.connect(done)
worker.failed.connect(lambda _e, p=src: self.show_document_text(p))
self._convert_worker = worker
worker.start()
def _ensure_pdf_view(self):
"""Dựng khung xem PDF một lần duy nhất; không có ``QtPdf`` thì trả ``None``
để chỗ gọi rơi về cách hiện văn bản thuần.
"""
if not HAS_PDF:
return None
if self._pdf_view is None:
from PySide6.QtPdf import QPdfDocument
from PySide6.QtPdfWidgets import QPdfView
self._pdf_doc = QPdfDocument(self._owner)
self._pdf_view = QPdfView(self._owner)
self._pdf_view.setDocument(self._pdf_doc)
try:
self._pdf_view.setPageMode(QPdfView.PageMode.MultiPage)
self._pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
except Exception: # noqa: BLE001 - enum names vary slightly across versions
pass
self._owner.stack.addWidget(self._pdf_view)
return self._pdf_view
def _render_pdf(self, pdf_path: str) -> None:
"""Nạp và hiện một tệp PDF; thiếu ``QtPdf`` thì rơi về trích văn bản."""
view = self._ensure_pdf_view()
if view is None:
self.show_document_text(pdf_path)
return
self._pdf_doc.load(pdf_path)
self._owner.stack.setCurrentWidget(view)
def show_document_text(self, path: str) -> None:
"""Cách dự phòng cuối: trích văn bản từ tài liệu và hiện dưới dạng chữ thuần.
Dùng khi không có LibreOffice để chuyển PDF, hoặc không có khung xem PDF.
"""
from cowork_local.core.doc_extract import extract_text
o = self._owner
try:
text, note = extract_text(path)
except Exception as exc: # noqa: BLE001
text, note = None, str(exc)
body = text if text else tr("folder.doc_unreadable", note=note or "?")
o.doc_view.setPlainText(body)
o.stack.setCurrentWidget(o.doc_view)
def write_pptx(self, content: str, skip_confirm: bool = False) -> bool:
"""Write edited pptx text back into the deck. If the edit REPLACES any
image, ask the user to confirm first. ``skip_confirm`` is used when
the image was already confirmed (e.g. just generated). Returns False
if the user declined."""
from cowork_local.core import pptx_edit
o = self._owner
if not skip_confirm and pptx_edit.image_change_requested(content):
from PySide6.QtWidgets import QMessageBox
ok = QMessageBox.question(o, tr("folder.ai_image_confirm_title"),
tr("folder.ai_image_confirm"))
if ok != QMessageBox.Yes:
o.status_message.emit(tr("folder.ai_image_declined"))
return False
pptx_edit.apply_text_to_pptx(o.current_file, content)
return True
__all__ = ["OfficeDocumentRenderer", "HAS_PDF"]
+106
View File
@@ -0,0 +1,106 @@
"""WorkspaceFileTree — the folder-picker bar + directory tree pane of the
Folder Explorer (R08-T12, extracted from ``ui/folder_tab.py::FolderTab``,
lines 264-293/386-408 of the original 1587-line file).
Owns navigation only: which root is browsed and which file was clicked.
Rendering/editing the SELECTED file is
``document_preview_manager.py::DocumentPreviewManager``'s job — this widget
just emits :attr:`file_selected`.
"""
from __future__ import annotations
import os
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFileDialog, QFileSystemModel, QHBoxLayout, QLabel, QPushButton,
QTreeView, QVBoxLayout, QWidget,
)
from cowork_local.i18n import tr
from cowork_local.ui.icons import icon
class WorkspaceFileTree(QWidget):
"""The left-hand tree pane: a path bar (label + "open folder" button)
above a ``QFileSystemModel``-backed ``QTreeView``."""
file_selected = Signal(str) # absolute path of the clicked file
root_changed = Signal(str) # absolute path of the new root
def __init__(self, initial_root: str, parent=None):
"""Cây tệp của thư mục làm việc, có ô đường dẫn ở trên làm tiêu đề màn hình."""
super().__init__(parent)
self._root = initial_root
root_layout = QVBoxLayout(self)
root_layout.setContentsMargins(0, 0, 0, 0)
# 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.
# 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_layout.addLayout(bar)
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)
root_layout.addWidget(self.tree, 1)
self.retranslate()
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self._open_btn.setToolTip(tr("folder.path_placeholder"))
self._open_btn.setText(tr("folder.open_folder"))
@property
def root(self) -> str:
"""Thư mục gốc đang hiện."""
return self._root
def set_root(self, path: str) -> None:
"""Đổi thư mục gốc; đường dẫn rỗng hoặc không tồn tại thì BỎ QUA lặng lẽ.
Chỗ gọi dựa vào việc ``root`` không đổi để biết cây đã từ chối đường dẫn.
"""
p = str(path or "").strip()
if not p or not os.path.isdir(p):
return
self._root = p
self.path_lbl.setText(p)
self.path_lbl.setToolTip(p)
self.model.setRootPath(p)
self.tree.setRootIndex(self.model.index(p))
self.root_changed.emit(p)
def _pick_root(self) -> None:
"""Mở hộp thoại chọn thư mục gốc."""
chosen = QFileDialog.getExistingDirectory(self, tr("folder.open_folder"), self._root)
if chosen:
self.set_root(chosen)
def _on_tree_clicked(self, index) -> None:
"""Bấm vào một mục: là tệp thì phát tín hiệu mở, là thư mục thì để cây tự bung."""
path = self.model.filePath(index)
if path and os.path.isfile(path):
self.file_selected.emit(path)
__all__ = ["WorkspaceFileTree"]