feat: 一系列新功能

This commit is contained in:
2026-01-08 00:05:00 +08:00
parent 1398368e18
commit c051f0ffdd
44 changed files with 1984 additions and 93 deletions
+267
View File
@@ -0,0 +1,267 @@
"""仓库编辑器, 使用TextArea控件等实现仓库配置编辑"""
import json
from pathlib import Path
from typing import Optional
import toml
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
from textual.reactive import reactive
from textual.screen import Screen
from textual.widgets import (
Button,
Footer,
Header,
Label,
ListItem,
ListView,
Static,
TextArea,
)
from heurams.context import config_var
from heurams.kernel.repolib import Repo
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class RepoEditorScreen(Screen):
"""仓库编辑器屏幕"""
SUB_TITLE = "仓库编辑器"
BINDINGS = [
("q", "go_back", "返回"),
("s", "save_file", "保存"),
("r", "reload_file", "重载"),
("d", "toggle_dark", ""),
]
# 当前选择的仓库路径
selected_repo_path: reactive[Optional[Path]] = reactive(None)
# 当前选择的文件名
selected_filename: reactive[Optional[str]] = reactive(None)
# 文件内容
file_content: reactive[str] = reactive("")
def __init__(
self,
repo: Optional[Repo] = None,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name, id, classes)
self.repo = repo
self.repo_dir: Optional[Path] = None
self.file_list = []
if repo is not None and repo.source is not None:
self.repo_dir = repo.source
self._load_file_list()
# selected_repo_path 将在 on_mount 中设置,避免触发watch时组件未就绪
def _load_file_list(self) -> None:
"""加载仓库目录下的文件列表"""
if self.repo_dir is None:
return
self.file_list = []
for fname in Repo.file_mapping.values():
fpath = self.repo_dir / fname
if fpath.exists():
self.file_list.append(fname)
# 也可能存在其他文件,但暂时只支持标准文件
self.file_list.sort()
def compose(self) -> ComposeResult:
"""组合界面组件"""
yield Header(show_clock=True)
with Container(id="main_container"):
with Horizontal(id="top_panel"):
# 左侧: 仓库选择
with Vertical(id="repo_selector", classes="panel"):
yield Label("仓库列表", classes="panel-title")
yield ListView(
*[
ListItem(Label(repo_dir.name))
for repo_dir in self._get_repo_dirs()
],
id="repo_list",
classes="list-view",
)
# 中间: 文件列表
with Vertical(id="file_selector", classes="panel"):
yield Label("文件列表", classes="panel-title")
yield ListView(
*[ListItem(Label(fname)) for fname in self.file_list],
id="file_list",
classes="list-view",
)
# 右侧: 编辑区域
with Vertical(id="editor_panel", classes="panel"):
yield Label("编辑文件", classes="panel-title")
yield TextArea(
id="text_editor",
language="plaintext",
classes="text-editor",
)
with Horizontal(id="button_bar"):
yield Button("保存", id="save_button", variant="primary")
yield Button("重载", id="reload_button", variant="default")
yield Button("返回", id="back_button", variant="error")
yield Footer()
def _get_repo_dirs(self) -> list[Path]:
"""获取data/repo/下所有有效仓库目录"""
repo_root = Path(config_var.get()["paths"]["data"]) / "repo"
repo_dirs = []
if repo_root.exists():
for entry in repo_root.iterdir():
if entry.is_dir():
# 检查是否存在 manifest.toml
if (entry / "manifest.toml").exists():
repo_dirs.append(entry)
return repo_dirs
def on_mount(self) -> None:
"""挂载组件时初始化"""
# 如果已有仓库,设置 selected_repo_path 以触发watch(此时组件已就绪)
if self.repo_dir is not None:
self.selected_repo_path = self.repo_dir
# 焦点放在仓库列表
self.query_one("#repo_list", ListView).focus()
def watch_selected_repo_path(
self, old_path: Optional[Path], new_path: Optional[Path]
) -> None:
"""当选择的仓库路径变化时,加载文件列表"""
if new_path is None:
self.file_list = []
self.selected_filename = None
self.file_content = ""
return
self.repo_dir = new_path
self._load_file_list()
# 如果组件已挂载,更新UI
if self.is_mounted:
file_list_view = self.query_one("#file_list", ListView)
file_list_view.clear()
for fname in self.file_list:
file_list_view.append(ListItem(Label(fname)))
# 清空编辑器
self.query_one("#text_editor", TextArea).text = ""
self.selected_filename = None
def watch_selected_filename(
self, old_name: Optional[str], new_name: Optional[str]
) -> None:
"""当选择的文件名变化时,加载文件内容"""
if new_name is None or self.repo_dir is None:
self.file_content = ""
return
file_path = self.repo_dir / new_name
if not file_path.exists():
self.notify(f"文件不存在: {new_name}", severity="error")
return
try:
content = file_path.read_text(encoding="utf-8")
self.file_content = content
# 如果组件已挂载,更新编辑器
if self.is_mounted:
editor = self.query_one("#text_editor", TextArea)
editor.text = content
# 根据文件后缀设置语言
if new_name.endswith(".toml"):
editor.language = "toml"
elif new_name.endswith(".json"):
editor.language = "json"
else:
editor.language = "plaintext"
except Exception as e:
logger.error(f"读取文件失败: {e}")
self.notify(f"读取文件失败: {e}", severity="error")
def watch_file_content(self, old_content: str, new_content: str) -> None:
"""当文件内容变化时更新编辑器(仅当外部改变时)"""
# 目前不需要做任何事情,因为编辑器内容已绑定
pass
def on_list_view_selected(self, event) -> None:
"""处理列表项选择事件"""
if not isinstance(event.item, ListItem):
return
list_id = event.list_view.id
selected_label = event.item.query_one(Label)
selected_text = str(selected_label.render())
if list_id == "repo_list":
# 用户选择了仓库
repo_root = Path(config_var.get()["paths"]["data"]) / "repo"
selected_dir = repo_root / selected_text
if selected_dir.exists():
self.selected_repo_path = selected_dir
elif list_id == "file_list":
# 用户选择了文件
if self.repo_dir is None:
self.notify("请先选择仓库", severity="warning")
return
self.selected_filename = selected_text
def on_button_pressed(self, event) -> None:
"""处理按钮点击事件"""
event.stop()
if event.button.id == "save_button":
self.action_save_file()
elif event.button.id == "reload_button":
self.action_reload_file()
elif event.button.id == "back_button":
self.action_go_back()
def action_save_file(self) -> None:
"""保存当前编辑的文件"""
if self.repo_dir is None or self.selected_filename is None:
self.notify("未选择仓库或文件", severity="warning")
return
file_path = self.repo_dir / self.selected_filename
editor = self.query_one("#text_editor", TextArea)
new_content = editor.text
# 验证格式
try:
if self.selected_filename.endswith(".toml"):
toml.loads(new_content) # 验证TOML
elif self.selected_filename.endswith(".json"):
json.loads(new_content) # 验证JSON
except Exception as e:
self.notify(f"格式错误: {e}", severity="error")
return
# 写入文件
try:
file_path.write_text(new_content, encoding="utf-8")
self.notify("保存成功", severity="information")
except Exception as e:
logger.error(f"保存文件失败: {e}")
self.notify(f"保存文件失败: {e}", severity="error")
def action_reload_file(self) -> None:
"""重新加载当前文件(放弃修改)"""
if self.repo_dir is None or self.selected_filename is None:
self.notify("未选择仓库或文件", severity="warning")
return
file_path = self.repo_dir / self.selected_filename
try:
content = file_path.read_text(encoding="utf-8")
editor = self.query_one("#text_editor", TextArea)
editor.text = content
self.notify("已重载", severity="information")
except Exception as e:
logger.error(f"重载文件失败: {e}")
self.notify(f"重载文件失败: {e}", severity="error")
def action_go_back(self) -> None:
"""返回上一屏幕"""
self.app.pop_screen()
def action_toggle_dark(self) -> None:
"""切换暗色模式"""
self.app.dark = not self.app.dark