├── src └── devhaven │ ├── __init__.py │ ├── app.py │ ├── presentation │ ├── __init__.py │ ├── types.py │ ├── terminal.py │ ├── screens.py │ └── app.py │ ├── __main__.py │ ├── db.py │ ├── infrastructure │ ├── __init__.py │ ├── config_store.py │ └── project_repository.py │ ├── domain │ ├── __init__.py │ └── models.py │ ├── application │ ├── __init__.py │ └── project_service.py │ └── config.py ├── .gitignore ├── pyproject.toml └── README.md /src/devhaven/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["__version__"] 2 | 3 | __version__ = "0.1.0" 4 | -------------------------------------------------------------------------------- /src/devhaven/app.py: -------------------------------------------------------------------------------- 1 | from devhaven.presentation.app import DevHavenApp 2 | 3 | __all__ = ["DevHavenApp"] 4 | -------------------------------------------------------------------------------- /src/devhaven/presentation/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import DevHavenApp 2 | 3 | __all__ = ["DevHavenApp"] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *.egg-info/ 4 | *.log 5 | .pytest_cache/ 6 | .venv/ 7 | dist/ 8 | build/ 9 | .idea/ 10 | .serena/ 11 | -------------------------------------------------------------------------------- /src/devhaven/__main__.py: -------------------------------------------------------------------------------- 1 | from devhaven.presentation.app import DevHavenApp 2 | 3 | 4 | def main() -> None: 5 | DevHavenApp().run() 6 | 7 | 8 | if __name__ == "__main__": 9 | main() 10 | -------------------------------------------------------------------------------- /src/devhaven/db.py: -------------------------------------------------------------------------------- 1 | from devhaven.domain import Project 2 | from devhaven.infrastructure.project_repository import ProjectRepository 3 | 4 | Database = ProjectRepository 5 | 6 | __all__ = ["Database", "Project"] 7 | -------------------------------------------------------------------------------- /src/devhaven/infrastructure/__init__.py: -------------------------------------------------------------------------------- 1 | from .config_store import load_config, save_config 2 | from .project_repository import ProjectRepository 3 | 4 | __all__ = ["ProjectRepository", "load_config", "save_config"] 5 | -------------------------------------------------------------------------------- /src/devhaven/domain/__init__.py: -------------------------------------------------------------------------------- 1 | from .models import Project, DEFAULT_SPACE, now_iso, normalize_space, normalize_tags 2 | 3 | __all__ = [ 4 | "DEFAULT_SPACE", 5 | "Project", 6 | "now_iso", 7 | "normalize_space", 8 | "normalize_tags", 9 | ] 10 | -------------------------------------------------------------------------------- /src/devhaven/application/__init__.py: -------------------------------------------------------------------------------- 1 | from .project_service import ImportSummary, build_open_command, default_open_command, import_projects 2 | 3 | __all__ = [ 4 | "ImportSummary", 5 | "build_open_command", 6 | "default_open_command", 7 | "import_projects", 8 | ] 9 | -------------------------------------------------------------------------------- /src/devhaven/config.py: -------------------------------------------------------------------------------- 1 | from devhaven.infrastructure.config_store import CONFIG_DIR_NAME, CONFIG_FILE_NAME, DEFAULT_CONFIG, config_path, data_dir, load_config, save_config 2 | 3 | __all__ = [ 4 | "CONFIG_DIR_NAME", 5 | "CONFIG_FILE_NAME", 6 | "DEFAULT_CONFIG", 7 | "config_path", 8 | "data_dir", 9 | "load_config", 10 | "save_config", 11 | ] 12 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "devhaven" 3 | version = "0.1.0" 4 | description = "TUI project manager" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | dependencies = [ 8 | "textual>=0.65.0", 9 | "textual-terminal>=0.1.0", 10 | ] 11 | 12 | [project.optional-dependencies] 13 | dev = [ 14 | "pytest>=7.4.0", 15 | ] 16 | 17 | [project.scripts] 18 | devhaven = "devhaven.__main__:main" 19 | 20 | [build-system] 21 | requires = ["setuptools>=68", "wheel"] 22 | build-backend = "setuptools.build_meta" 23 | 24 | [tool.setuptools] 25 | package-dir = {"" = "src"} 26 | 27 | [tool.setuptools.packages.find] 28 | where = ["src"] 29 | -------------------------------------------------------------------------------- /src/devhaven/presentation/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass(frozen=True) 7 | class ProjectFormResult: 8 | name: str 9 | path: str 10 | space: str 11 | tags: list[str] 12 | 13 | 14 | @dataclass(frozen=True) 15 | class ImportDirectoryResult: 16 | path: str 17 | space: str 18 | tags: list[str] 19 | 20 | 21 | @dataclass(frozen=True) 22 | class TreeItem: 23 | kind: str 24 | value: str | int 25 | 26 | @staticmethod 27 | def space(name: str) -> "TreeItem": 28 | return TreeItem(kind="space", value=name) 29 | 30 | @staticmethod 31 | def project(project_id: int) -> "TreeItem": 32 | return TreeItem(kind="project", value=project_id) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DevHaven TUI 2 | 3 | 一个基于 Textual 的本地项目管理 TUI。 4 | 5 | ## 运行环境 6 | - Python 3.10+ 7 | - uv 8 | 9 | ## 安装 10 | 11 | 创建虚拟环境并安装依赖: 12 | 13 | ```bash 14 | uv venv 15 | uv pip install -e ".[dev]" 16 | ``` 17 | 18 | 启动: 19 | 20 | ```bash 21 | python -m devhaven 22 | ``` 23 | 24 | ## 快捷键 25 | - `a` 新增项目 26 | - `e` 编辑项目 27 | - `d` 删除项目 28 | - `i` 导入目录(一键导入父目录下一层子目录) 29 | - `o` 打开项目 30 | - `/` 搜索 31 | - `空格` 展开/收起空间节点 32 | - `q` 退出 33 | 34 | ## 路径输入更省心 35 | - 新增/编辑项目时,点击“选择目录”可直接浏览文件夹 36 | - 名称留空会自动使用所选路径的文件夹名 37 | 38 | ## 空间(分组) 39 | - 空间用于分类项目,默认值为“个人” 40 | - 导入目录时,空间会自动使用父目录名(可手动覆盖) 41 | - 左侧以“空间 -> 项目”的层级结构展示 42 | 43 | ## 数据位置 44 | - 配置与数据:`~/.devhaven/config.json` 45 | 46 | ## 配置说明 47 | 48 | 示例(JSON): 49 | 50 | ```json 51 | { 52 | "open_command": "code {path}", 53 | "projects": [], 54 | "next_project_id": 1 55 | } 56 | ``` 57 | 58 | 如果 `open_command` 为空或缺失,则使用系统默认打开方式。 59 | -------------------------------------------------------------------------------- /src/devhaven/domain/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from datetime import datetime, timezone 5 | from typing import Iterable 6 | 7 | DEFAULT_SPACE = "个人" 8 | 9 | 10 | @dataclass(frozen=True) 11 | class Project: 12 | id: int 13 | name: str 14 | path: str 15 | space: str 16 | tags: list[str] 17 | created_at: str 18 | updated_at: str 19 | last_opened_at: str | None 20 | 21 | 22 | def now_iso() -> str: 23 | return datetime.now(timezone.utc).replace(microsecond=0).isoformat() 24 | 25 | 26 | def normalize_tags(tags: Iterable[str]) -> list[str]: 27 | normalized: list[str] = [] 28 | for tag in tags: 29 | item = str(tag).strip() 30 | if item and item not in normalized: 31 | normalized.append(item) 32 | return normalized 33 | 34 | 35 | def normalize_space(space: str) -> str: 36 | candidate = space.strip() 37 | return candidate or DEFAULT_SPACE 38 | -------------------------------------------------------------------------------- /src/devhaven/infrastructure/config_store.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from copy import deepcopy 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | CONFIG_DIR_NAME = ".devhaven" 9 | CONFIG_FILE_NAME = "config.json" 10 | DEFAULT_CONFIG: dict[str, Any] = { 11 | "open_command": "", 12 | "projects": [], 13 | "next_project_id": 1, 14 | } 15 | 16 | 17 | def data_dir() -> Path: 18 | return Path.home() / CONFIG_DIR_NAME 19 | 20 | 21 | def config_path() -> Path: 22 | return data_dir() / CONFIG_FILE_NAME 23 | 24 | 25 | def load_config() -> dict[str, Any]: 26 | path = config_path() 27 | if not path.exists(): 28 | return deepcopy(DEFAULT_CONFIG) 29 | try: 30 | payload = json.loads(path.read_text(encoding="utf-8")) 31 | except json.JSONDecodeError: 32 | return deepcopy(DEFAULT_CONFIG) 33 | base = deepcopy(DEFAULT_CONFIG) 34 | if isinstance(payload, dict): 35 | base.update(payload) 36 | return base 37 | 38 | 39 | def save_config(config: dict[str, Any]) -> None: 40 | path = config_path() 41 | path.parent.mkdir(parents=True, exist_ok=True) 42 | path.write_text( 43 | json.dumps(config, ensure_ascii=False, indent=2, sort_keys=True), 44 | encoding="utf-8", 45 | ) 46 | -------------------------------------------------------------------------------- /src/devhaven/presentation/terminal.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import os 5 | import pty 6 | from pathlib import Path 7 | import shlex 8 | 9 | from textual import app as textual_app 10 | from textual import events 11 | from textual.theme import BUILTIN_THEMES 12 | 13 | if not hasattr(textual_app, "DEFAULT_COLORS"): 14 | textual_app.DEFAULT_COLORS = { 15 | "dark": BUILTIN_THEMES["textual-dark"].to_color_system(), 16 | "light": BUILTIN_THEMES["textual-light"].to_color_system(), 17 | } 18 | 19 | from textual_terminal import Terminal 20 | from textual_terminal._terminal import TerminalEmulator as BaseTerminalEmulator 21 | 22 | 23 | class ProjectTerminalEmulator(BaseTerminalEmulator): 24 | def open_terminal(self, command: str): 25 | self.pid, fd = pty.fork() 26 | if self.pid == 0: 27 | argv = shlex.split(command) 28 | env = os.environ.copy() 29 | env.setdefault("TERM", "xterm") 30 | env.setdefault("LC_ALL", "en_US.UTF-8") 31 | env.setdefault("HOME", str(Path.home())) 32 | os.execvpe(argv[0], argv, env) 33 | return fd 34 | 35 | 36 | class ProjectTerminal(Terminal): 37 | def start(self) -> None: 38 | if self.emulator is not None: 39 | return 40 | self.emulator = ProjectTerminalEmulator(command=self.command) 41 | self.emulator.start() 42 | self.send_queue = self.emulator.recv_queue 43 | self.recv_queue = self.emulator.send_queue 44 | self.recv_task = asyncio.create_task(self.recv()) 45 | 46 | async def on_key(self, event: events.Key) -> None: 47 | if self.emulator is None: 48 | return 49 | 50 | if event.key == "ctrl+f1": 51 | self.app.set_focus(None) 52 | return 53 | 54 | event.prevent_default() 55 | event.stop() 56 | char = self.ctrl_keys.get(event.key) 57 | if char is None and event.key.startswith("ctrl+"): 58 | key = event.key.removeprefix("ctrl+") 59 | if len(key) == 1 and key.isalpha(): 60 | char = chr(ord(key.lower()) - 96) 61 | if char is None: 62 | char = event.character 63 | if char: 64 | await self.send_queue.put(["stdin", char]) 65 | -------------------------------------------------------------------------------- /src/devhaven/application/project_service.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from pathlib import Path 5 | import shlex 6 | import sys 7 | 8 | from devhaven.domain import DEFAULT_SPACE 9 | from devhaven.infrastructure.config_store import load_config 10 | from devhaven.infrastructure.project_repository import ProjectRepository 11 | 12 | 13 | @dataclass(frozen=True) 14 | class ImportSummary: 15 | added: int 16 | skipped_existing: int 17 | skipped_hidden: int 18 | total_children: int 19 | space: str 20 | 21 | 22 | def build_open_command(path: str) -> list[str]: 23 | config = load_config() 24 | open_command = str(config.get("open_command", "") or "").strip() 25 | path_value = Path(path).expanduser().resolve().as_posix() 26 | if open_command: 27 | tokens = shlex.split(open_command) 28 | if any("{path}" in token for token in tokens): 29 | return [token.replace("{path}", path_value) for token in tokens] 30 | return [*tokens, path_value] 31 | return default_open_command(path_value) 32 | 33 | 34 | def default_open_command(path: str) -> list[str]: 35 | if sys.platform.startswith("darwin"): 36 | return ["open", path] 37 | if sys.platform.startswith("win"): 38 | return ["cmd", "/c", "start", "", path] 39 | return ["xdg-open", path] 40 | 41 | 42 | def import_projects( 43 | repository: ProjectRepository, 44 | parent_path: Path, 45 | space: str, 46 | tags: list[str], 47 | ) -> ImportSummary: 48 | if not parent_path.exists() or not parent_path.is_dir(): 49 | raise FileNotFoundError(parent_path) 50 | children = [child for child in parent_path.iterdir() if child.is_dir()] 51 | space_value = space.strip() or parent_path.name.strip() or DEFAULT_SPACE 52 | existing_paths = repository.list_project_paths() 53 | added = 0 54 | skipped_existing = 0 55 | skipped_hidden = 0 56 | for child in sorted(children, key=lambda item: item.name.lower()): 57 | if child.name.startswith("."): 58 | skipped_hidden += 1 59 | continue 60 | path_value = child.resolve().as_posix() 61 | if path_value in existing_paths: 62 | skipped_existing += 1 63 | continue 64 | repository.create_project(child.name, path_value, space_value, tags) 65 | existing_paths.add(path_value) 66 | added += 1 67 | return ImportSummary( 68 | added=added, 69 | skipped_existing=skipped_existing, 70 | skipped_hidden=skipped_hidden, 71 | total_children=len(children), 72 | space=space_value, 73 | ) 74 | -------------------------------------------------------------------------------- /src/devhaven/infrastructure/project_repository.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Iterable 4 | 5 | from devhaven.domain import Project, now_iso, normalize_space, normalize_tags 6 | from devhaven.infrastructure.config_store import load_config, save_config 7 | 8 | 9 | class ProjectRepository: 10 | def ensure_schema(self) -> None: 11 | config = self._load_state() 12 | save_config(config) 13 | 14 | def list_projects(self, query: str = "") -> list[Project]: 15 | config = self._load_state() 16 | projects = [project for project in self._load_projects(config) if project is not None] 17 | query = query.strip().lower() 18 | if query: 19 | projects = [project for project in projects if self._matches_query(project, query)] 20 | projects = sorted(projects, key=lambda item: item.name.lower()) 21 | projects = sorted(projects, key=lambda item: item.last_opened_at or "", reverse=True) 22 | return projects 23 | 24 | def list_project_paths(self) -> set[str]: 25 | config = self._load_state() 26 | paths: set[str] = set() 27 | for item in config["projects"]: 28 | if not isinstance(item, dict): 29 | continue 30 | path = item.get("path") 31 | if isinstance(path, str) and path: 32 | paths.add(path) 33 | return paths 34 | 35 | def get_project(self, project_id: int) -> Project | None: 36 | config = self._load_state() 37 | for item in config["projects"]: 38 | project = self._project_from_dict(item) 39 | if project and project.id == project_id: 40 | return project 41 | return None 42 | 43 | def create_project(self, name: str, path: str, space: str, tags: Iterable[str]) -> None: 44 | config = self._load_state() 45 | now = now_iso() 46 | project_id = config["next_project_id"] 47 | config["next_project_id"] = project_id + 1 48 | config["projects"].append( 49 | { 50 | "id": project_id, 51 | "name": name, 52 | "path": path, 53 | "space": normalize_space(space), 54 | "tags": normalize_tags(tags), 55 | "created_at": now, 56 | "updated_at": now, 57 | "last_opened_at": None, 58 | } 59 | ) 60 | save_config(config) 61 | 62 | def update_project(self, project_id: int, name: str, path: str, space: str, tags: Iterable[str]) -> None: 63 | config = self._load_state() 64 | project = self._find_project(config["projects"], project_id) 65 | if project is None: 66 | return 67 | project["name"] = name 68 | project["path"] = path 69 | project["space"] = normalize_space(space) 70 | project["tags"] = normalize_tags(tags) 71 | project["updated_at"] = now_iso() 72 | save_config(config) 73 | 74 | def delete_project(self, project_id: int) -> None: 75 | config = self._load_state() 76 | original = config["projects"] 77 | config["projects"] = [ 78 | item for item in original if not (isinstance(item, dict) and self._project_id(item) == project_id) 79 | ] 80 | if len(config["projects"]) != len(original): 81 | save_config(config) 82 | 83 | def touch_opened(self, project_id: int) -> None: 84 | config = self._load_state() 85 | project = self._find_project(config["projects"], project_id) 86 | if project is None: 87 | return 88 | now = now_iso() 89 | project["last_opened_at"] = now 90 | project["updated_at"] = now 91 | save_config(config) 92 | 93 | def _load_state(self) -> dict[str, Any]: 94 | config = load_config() 95 | projects = config.get("projects") 96 | if not isinstance(projects, list): 97 | projects = [] 98 | config["projects"] = projects 99 | next_id = config.get("next_project_id") 100 | if not isinstance(next_id, int) or next_id < 1: 101 | next_id = self._next_project_id(projects) 102 | config["next_project_id"] = next_id 103 | return config 104 | 105 | def _load_projects(self, config: dict[str, Any]) -> list[Project | None]: 106 | return [self._project_from_dict(item) for item in config["projects"]] 107 | 108 | def _project_from_dict(self, data: Any) -> Project | None: 109 | if not isinstance(data, dict): 110 | return None 111 | try: 112 | project_id = int(data.get("id")) 113 | except (TypeError, ValueError): 114 | return None 115 | name = str(data.get("name", "")).strip() 116 | path = str(data.get("path", "")).strip() 117 | if not name or not path: 118 | return None 119 | space = normalize_space(str(data.get("space", ""))) 120 | tags = self._coerce_tags(data.get("tags")) 121 | created_at = str(data.get("created_at") or now_iso()) 122 | updated_at = str(data.get("updated_at") or created_at) 123 | last_opened_at = data.get("last_opened_at") 124 | last_opened = str(last_opened_at) if last_opened_at else None 125 | return Project( 126 | id=project_id, 127 | name=name, 128 | path=path, 129 | space=space, 130 | tags=tags, 131 | created_at=created_at, 132 | updated_at=updated_at, 133 | last_opened_at=last_opened, 134 | ) 135 | 136 | def _coerce_tags(self, value: Any) -> list[str]: 137 | if isinstance(value, list): 138 | return normalize_tags(value) 139 | if isinstance(value, str): 140 | return normalize_tags([item for item in value.split(",")]) 141 | return [] 142 | 143 | def _matches_query(self, project: Project, query: str) -> bool: 144 | haystack = [ 145 | project.name.lower(), 146 | project.path.lower(), 147 | project.space.lower(), 148 | ",".join(project.tags).lower(), 149 | ] 150 | return any(query in item for item in haystack) 151 | 152 | def _next_project_id(self, projects: list[Any]) -> int: 153 | max_id = 0 154 | for item in projects: 155 | if not isinstance(item, dict): 156 | continue 157 | project_id = self._project_id(item) 158 | if project_id > max_id: 159 | max_id = project_id 160 | return max_id + 1 if max_id >= 1 else 1 161 | 162 | def _project_id(self, data: dict[str, Any]) -> int: 163 | try: 164 | return int(data.get("id", 0)) 165 | except (TypeError, ValueError): 166 | return 0 167 | 168 | def _find_project(self, projects: list[Any], project_id: int) -> dict[str, Any] | None: 169 | for item in projects: 170 | if not isinstance(item, dict): 171 | continue 172 | if self._project_id(item) == project_id: 173 | return item 174 | return None 175 | -------------------------------------------------------------------------------- /src/devhaven/presentation/screens.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | from textual.app import ComposeResult 6 | from textual.containers import Container, Horizontal 7 | from textual.screen import ModalScreen 8 | from textual.widgets import Button, DirectoryTree, Input, Label, Static 9 | 10 | from devhaven.domain import Project 11 | from devhaven.presentation.types import ImportDirectoryResult, ProjectFormResult 12 | 13 | 14 | class ProjectForm(ModalScreen[ProjectFormResult | None]): 15 | CSS = """ 16 | Screen { 17 | align: center middle; 18 | } 19 | 20 | #dialog { 21 | width: 70%; 22 | max-width: 80; 23 | border: solid $primary; 24 | padding: 1 2; 25 | background: $panel; 26 | } 27 | 28 | #dialog_title { 29 | text-style: bold; 30 | margin-bottom: 1; 31 | } 32 | 33 | .field { 34 | margin-bottom: 1; 35 | } 36 | 37 | .field_row { 38 | height: auto; 39 | margin-bottom: 1; 40 | } 41 | 42 | #path_input { 43 | width: 1fr; 44 | } 45 | 46 | #browse_path { 47 | width: auto; 48 | margin-left: 1; 49 | } 50 | 51 | #buttons { 52 | height: auto; 53 | margin-top: 1; 54 | align: center middle; 55 | } 56 | 57 | #error { 58 | color: red; 59 | height: auto; 60 | } 61 | """ 62 | 63 | def __init__(self, title: str, project: Project | None = None) -> None: 64 | super().__init__() 65 | self.title = title 66 | self.project = project 67 | 68 | def compose(self) -> ComposeResult: 69 | with Container(id="dialog"): 70 | yield Static(self.title, id="dialog_title") 71 | yield Label("名称(留空自动填充)", classes="field") 72 | yield Input(value=self.project.name if self.project else "", id="name_input") 73 | yield Label("空间(默认个人)", classes="field") 74 | yield Input(value=self.project.space if self.project else "", id="space_input") 75 | yield Label("路径", classes="field") 76 | with Horizontal(classes="field_row"): 77 | yield Input(value=self.project.path if self.project else "", id="path_input") 78 | yield Button("选择目录", id="browse_path") 79 | yield Label("标签(用英文逗号分隔)", classes="field") 80 | yield Input(value=", ".join(self.project.tags) if self.project else "", id="tags_input") 81 | yield Static("", id="error") 82 | with Horizontal(id="buttons"): 83 | yield Button("保存", id="save", variant="primary") 84 | yield Button("取消", id="cancel") 85 | 86 | def on_mount(self) -> None: 87 | self.query_one("#name_input", Input).focus() 88 | 89 | def on_button_pressed(self, event: Button.Pressed) -> None: 90 | if event.button.id == "cancel": 91 | self.dismiss(None) 92 | return 93 | if event.button.id == "browse_path": 94 | self._open_directory_picker("#path_input") 95 | return 96 | if event.button.id != "save": 97 | return 98 | 99 | name = self.query_one("#name_input", Input).value.strip() 100 | space_value = self.query_one("#space_input", Input).value.strip() 101 | path_value = self.query_one("#path_input", Input).value.strip() 102 | tags_value = self.query_one("#tags_input", Input).value.strip() 103 | error = self.query_one("#error", Static) 104 | 105 | if not path_value: 106 | error.update("请填写路径。") 107 | return 108 | 109 | candidate_path = Path(path_value).expanduser() 110 | if not candidate_path.exists(): 111 | error.update("路径不存在。") 112 | return 113 | 114 | if not name: 115 | name = candidate_path.name.strip() 116 | if not name: 117 | error.update("无法从路径推断名称。") 118 | return 119 | 120 | space = space_value or "个人" 121 | tags = [item.strip() for item in tags_value.split(",") if item.strip()] 122 | result = ProjectFormResult( 123 | name=name, 124 | path=candidate_path.as_posix(), 125 | space=space, 126 | tags=tags, 127 | ) 128 | self.dismiss(result) 129 | 130 | def _open_directory_picker(self, input_selector: str) -> None: 131 | input_widget = self.query_one(input_selector, Input) 132 | current = Path(input_widget.value).expanduser() if input_widget.value else Path.home() 133 | start_path = current if current.exists() else Path.home() 134 | self.app.push_screen( 135 | DirectoryPicker(start_path), 136 | lambda selected: self._apply_directory_selection(input_selector, selected), 137 | ) 138 | 139 | def _apply_directory_selection(self, input_selector: str, selected: Path | None) -> None: 140 | if selected is None: 141 | return 142 | self.query_one(input_selector, Input).value = selected.as_posix() 143 | 144 | 145 | class DirectoryPicker(ModalScreen[Path | None]): 146 | CSS = """ 147 | Screen { 148 | align: center middle; 149 | } 150 | 151 | #dialog { 152 | width: 80%; 153 | height: 80%; 154 | border: solid $primary; 155 | padding: 1; 156 | background: $panel; 157 | } 158 | 159 | #tree { 160 | height: 1fr; 161 | border: solid $secondary; 162 | margin-bottom: 1; 163 | } 164 | 165 | #buttons { 166 | height: auto; 167 | align: center middle; 168 | } 169 | """ 170 | 171 | def __init__(self, start_path: Path) -> None: 172 | super().__init__() 173 | self.start_path = start_path 174 | self.selected_path: Path | None = None 175 | 176 | def compose(self) -> ComposeResult: 177 | with Container(id="dialog"): 178 | yield Static("选择目录", id="dialog_title") 179 | yield DirectoryTree(str(self.start_path), id="tree") 180 | with Horizontal(id="buttons"): 181 | yield Button("选择", id="select", variant="primary") 182 | yield Button("取消", id="cancel") 183 | 184 | def on_directory_tree_directory_selected(self, event: DirectoryTree.DirectorySelected) -> None: 185 | self.selected_path = event.path 186 | 187 | def on_button_pressed(self, event: Button.Pressed) -> None: 188 | if event.button.id == "cancel": 189 | self.dismiss(None) 190 | return 191 | if event.button.id != "select": 192 | return 193 | self.dismiss(self.selected_path) 194 | 195 | 196 | class ImportDirectoryForm(ModalScreen[ImportDirectoryResult | None]): 197 | CSS = """ 198 | Screen { 199 | align: center middle; 200 | } 201 | 202 | #dialog { 203 | width: 70%; 204 | max-width: 80; 205 | border: solid $primary; 206 | padding: 1 2; 207 | background: $panel; 208 | } 209 | 210 | #dialog_title { 211 | text-style: bold; 212 | margin-bottom: 1; 213 | } 214 | 215 | .field { 216 | margin-bottom: 1; 217 | } 218 | 219 | .field_row { 220 | height: auto; 221 | margin-bottom: 1; 222 | } 223 | 224 | #import_path_input { 225 | width: 1fr; 226 | } 227 | 228 | #browse_import_path { 229 | width: auto; 230 | margin-left: 1; 231 | } 232 | 233 | #buttons { 234 | height: auto; 235 | margin-top: 1; 236 | align: center middle; 237 | } 238 | 239 | #error { 240 | color: red; 241 | height: auto; 242 | } 243 | """ 244 | 245 | def compose(self) -> ComposeResult: 246 | with Container(id="dialog"): 247 | yield Static("导入目录", id="dialog_title") 248 | yield Label("父目录(将导入其一层子目录)", classes="field") 249 | with Horizontal(classes="field_row"): 250 | yield Input(placeholder="例如:/Users/xxx/Projects", id="import_path_input") 251 | yield Button("选择目录", id="browse_import_path") 252 | yield Label("空间(留空自动使用父目录名)", classes="field") 253 | yield Input(id="import_space_input") 254 | yield Label("标签(用英文逗号分隔,可选)", classes="field") 255 | yield Input(id="import_tags_input") 256 | yield Static("", id="error") 257 | with Horizontal(id="buttons"): 258 | yield Button("导入", id="import", variant="primary") 259 | yield Button("取消", id="cancel") 260 | 261 | def on_mount(self) -> None: 262 | self.query_one("#import_path_input", Input).focus() 263 | 264 | def on_button_pressed(self, event: Button.Pressed) -> None: 265 | if event.button.id == "cancel": 266 | self.dismiss(None) 267 | return 268 | if event.button.id == "browse_import_path": 269 | self._open_directory_picker("#import_path_input") 270 | return 271 | if event.button.id != "import": 272 | return 273 | 274 | path_value = self.query_one("#import_path_input", Input).value.strip() 275 | space_value = self.query_one("#import_space_input", Input).value.strip() 276 | tags_value = self.query_one("#import_tags_input", Input).value.strip() 277 | error = self.query_one("#error", Static) 278 | 279 | if not path_value: 280 | error.update("请填写父目录路径。") 281 | return 282 | 283 | candidate_path = Path(path_value).expanduser() 284 | if not candidate_path.exists() or not candidate_path.is_dir(): 285 | error.update("父目录不存在或不是文件夹。") 286 | return 287 | 288 | tags = [item.strip() for item in tags_value.split(",") if item.strip()] 289 | result = ImportDirectoryResult(path=candidate_path.as_posix(), space=space_value, tags=tags) 290 | self.dismiss(result) 291 | 292 | def _open_directory_picker(self, input_selector: str) -> None: 293 | input_widget = self.query_one(input_selector, Input) 294 | current = Path(input_widget.value).expanduser() if input_widget.value else Path.home() 295 | start_path = current if current.exists() else Path.home() 296 | self.app.push_screen( 297 | DirectoryPicker(start_path), 298 | lambda selected: self._apply_directory_selection(input_selector, selected), 299 | ) 300 | 301 | def _apply_directory_selection(self, input_selector: str, selected: Path | None) -> None: 302 | if selected is None: 303 | return 304 | self.query_one(input_selector, Input).value = selected.as_posix() 305 | 306 | 307 | class ConfirmDelete(ModalScreen[bool]): 308 | CSS = """ 309 | Screen { 310 | align: center middle; 311 | } 312 | 313 | #dialog { 314 | width: 60%; 315 | max-width: 70; 316 | border: solid $warning; 317 | padding: 1 2; 318 | background: $panel; 319 | } 320 | 321 | #buttons { 322 | height: auto; 323 | margin-top: 1; 324 | align: center middle; 325 | } 326 | """ 327 | 328 | def __init__(self, project_name: str) -> None: 329 | super().__init__() 330 | self.project_name = project_name 331 | 332 | def compose(self) -> ComposeResult: 333 | with Container(id="dialog"): 334 | yield Static(f"确定删除项目“{self.project_name}”?") 335 | with Horizontal(id="buttons"): 336 | yield Button("删除", id="delete", variant="error") 337 | yield Button("取消", id="cancel") 338 | 339 | def on_button_pressed(self, event: Button.Pressed) -> None: 340 | if event.button.id == "delete": 341 | self.dismiss(True) 342 | else: 343 | self.dismiss(False) 344 | 345 | 346 | class MessageScreen(ModalScreen[None]): 347 | CSS = """ 348 | Screen { 349 | align: center middle; 350 | } 351 | 352 | #dialog { 353 | width: 70%; 354 | max-width: 80; 355 | border: solid $primary; 356 | padding: 1 2; 357 | background: $panel; 358 | } 359 | 360 | #buttons { 361 | height: auto; 362 | margin-top: 1; 363 | align: center middle; 364 | } 365 | """ 366 | 367 | def __init__(self, title: str, message: str) -> None: 368 | super().__init__() 369 | self.title = title 370 | self.message = message 371 | 372 | def compose(self) -> ComposeResult: 373 | with Container(id="dialog"): 374 | yield Static(self.title, id="dialog_title") 375 | yield Static(self.message) 376 | with Horizontal(id="buttons"): 377 | yield Button("确定", id="ok", variant="primary") 378 | 379 | def on_button_pressed(self, event: Button.Pressed) -> None: 380 | self.dismiss(None) 381 | -------------------------------------------------------------------------------- /src/devhaven/presentation/app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | import subprocess 6 | import sys 7 | 8 | from rich.columns import Columns 9 | from rich.console import Group 10 | from rich.panel import Panel 11 | from rich.text import Text 12 | from textual import app as textual_app 13 | from textual.app import App, ComposeResult 14 | from textual.containers import Container, Horizontal, Vertical, VerticalScroll 15 | from textual.theme import BUILTIN_THEMES 16 | from textual.widgets import Footer, Header, Input, Static, Tree 17 | 18 | from devhaven.application.project_service import build_open_command, import_projects 19 | from devhaven.domain import Project 20 | from devhaven.infrastructure.project_repository import ProjectRepository 21 | from devhaven.presentation.screens import ( 22 | ConfirmDelete, 23 | ImportDirectoryForm, 24 | MessageScreen, 25 | ProjectForm, 26 | ) 27 | from devhaven.presentation.terminal import ProjectTerminal 28 | from devhaven.presentation.types import ImportDirectoryResult, ProjectFormResult, TreeItem 29 | 30 | if not hasattr(textual_app, "DEFAULT_COLORS"): 31 | textual_app.DEFAULT_COLORS = { 32 | "dark": BUILTIN_THEMES["textual-dark"].to_color_system(), 33 | "light": BUILTIN_THEMES["textual-light"].to_color_system(), 34 | } 35 | 36 | 37 | class DevHavenApp(App): 38 | CSS = """ 39 | #body { 40 | height: 1fr; 41 | } 42 | 43 | #sidebar { 44 | width: 40%; 45 | min-width: 30; 46 | border-right: solid $primary; 47 | } 48 | 49 | #detail_panel { 50 | width: 1fr; 51 | padding: 1 2; 52 | } 53 | 54 | #search { 55 | margin: 1 1 0 1; 56 | } 57 | 58 | #project_tree { 59 | height: 1fr; 60 | margin: 1; 61 | } 62 | 63 | #detail_scroll { 64 | border: solid $secondary; 65 | padding: 1; 66 | height: 2fr; 67 | } 68 | 69 | #detail { 70 | height: auto; 71 | } 72 | 73 | #terminal_panel { 74 | border: solid $secondary; 75 | padding: 0 1; 76 | margin-top: 1; 77 | height: 1fr; 78 | } 79 | 80 | #terminal { 81 | height: 1fr; 82 | } 83 | """ 84 | 85 | BINDINGS = [ 86 | ("q", "quit", "退出"), 87 | ("a", "add_project", "新增"), 88 | ("e", "edit_project", "编辑"), 89 | ("d", "delete_project", "删除"), 90 | ("i", "import_projects", "导入"), 91 | ("o", "open_project", "打开"), 92 | ("enter", "open_project", "打开"), 93 | ("r", "refresh", "刷新"), 94 | ("/", "focus_search", "搜索"), 95 | ] 96 | 97 | def __init__(self) -> None: 98 | super().__init__() 99 | self.repository = ProjectRepository() 100 | self.projects: list[Project] = [] 101 | self.project_map: dict[int, Project] = {} 102 | self.space_counts: dict[str, int] = {} 103 | self.selected_project_id: int | None = None 104 | self.selected_space: str | None = None 105 | self.query_text = "" 106 | 107 | def compose(self) -> ComposeResult: 108 | yield Header(show_clock=True) 109 | with Horizontal(id="body"): 110 | with Vertical(id="sidebar"): 111 | yield Input(placeholder="搜索", id="search") 112 | tree = Tree("空间", id="project_tree") 113 | tree.show_root = False 114 | yield tree 115 | with Vertical(id="detail_panel"): 116 | with VerticalScroll(id="detail_scroll"): 117 | yield Static("未选择项目。", id="detail") 118 | with Container(id="terminal_panel"): 119 | yield ProjectTerminal( 120 | command=self._terminal_command(), 121 | id="terminal", 122 | ) 123 | yield Footer() 124 | 125 | def on_mount(self) -> None: 126 | self.repository.ensure_schema() 127 | self._start_terminal() 128 | self.refresh_projects() 129 | self.query_one("#search", Input).focus() 130 | 131 | def _terminal_command(self) -> str: 132 | if sys.platform.startswith("win"): 133 | return os.environ.get("COMSPEC", "cmd") 134 | return os.environ.get("SHELL") or "bash" 135 | 136 | def _start_terminal(self) -> None: 137 | terminal = self.query_one("#terminal", ProjectTerminal) 138 | terminal.start() 139 | 140 | def _normalize_terminal_path(self, path: str) -> str: 141 | resolved = Path(path).expanduser().resolve() 142 | if sys.platform.startswith("win"): 143 | return str(resolved) 144 | return resolved.as_posix() 145 | 146 | def _quote_terminal_path(self, path: str) -> str: 147 | escaped = path.replace('"', '\\"') 148 | return f"\"{escaped}\"" 149 | 150 | def _build_cd_command(self, path: str) -> str: 151 | path_value = self._normalize_terminal_path(path) 152 | quoted = self._quote_terminal_path(path_value) 153 | if sys.platform.startswith("win"): 154 | return f"cd /d {quoted}\r\n" 155 | return f"cd {quoted}\n" 156 | 157 | def _update_terminal_cwd(self, path: str | None) -> None: 158 | if not path: 159 | return 160 | terminal = self.query_one("#terminal", ProjectTerminal) 161 | if terminal.send_queue is None: 162 | return 163 | terminal.send_queue.put_nowait(["stdin", self._build_cd_command(path)]) 164 | 165 | def on_input_changed(self, event: Input.Changed) -> None: 166 | if event.input.id == "search": 167 | self.refresh_projects(event.value) 168 | 169 | def on_tree_node_selected(self, event: Tree.NodeSelected) -> None: 170 | self._handle_tree_selection(event.node.data) 171 | 172 | def on_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None: 173 | self._handle_tree_selection(event.node.data) 174 | 175 | def action_focus_search(self) -> None: 176 | self.query_one("#search", Input).focus() 177 | 178 | def action_refresh(self) -> None: 179 | self.refresh_projects(self.query_text) 180 | 181 | def action_add_project(self) -> None: 182 | self.push_screen(ProjectForm("新增项目"), self._handle_add_result) 183 | 184 | def action_edit_project(self) -> None: 185 | project = self.get_selected_project() 186 | if not project: 187 | return 188 | self.push_screen(ProjectForm("编辑项目", project), lambda result: self._handle_edit_result(project.id, result)) 189 | 190 | def action_import_projects(self) -> None: 191 | self.push_screen(ImportDirectoryForm(), self._handle_import_result) 192 | 193 | def action_delete_project(self) -> None: 194 | project = self.get_selected_project() 195 | if not project: 196 | return 197 | self.push_screen(ConfirmDelete(project.name), lambda confirmed: self._handle_delete_result(project.id, confirmed)) 198 | 199 | def action_open_project(self) -> None: 200 | project = self.get_selected_project() 201 | if not project: 202 | return 203 | try: 204 | command = build_open_command(project.path) 205 | subprocess.Popen(command) 206 | except OSError as exc: 207 | self.push_screen(MessageScreen("打开失败", str(exc))) 208 | return 209 | self.repository.touch_opened(project.id) 210 | self.refresh_projects(self.query_text) 211 | 212 | def refresh_projects(self, query: str | None = None) -> None: 213 | if query is not None: 214 | self.query_text = query 215 | self.projects = self.repository.list_projects(self.query_text) 216 | self.project_map = {project.id: project for project in self.projects} 217 | self.space_counts = {} 218 | for project in self.projects: 219 | self.space_counts[project.space] = self.space_counts.get(project.space, 0) + 1 220 | 221 | tree = self.query_one("#project_tree", Tree) 222 | previous_project = self.selected_project_id 223 | previous_space = self.selected_space 224 | tree.clear() 225 | tree.root.label = "空间" 226 | tree.root.expand() 227 | 228 | space_nodes: dict[str, object] = {} 229 | project_nodes: dict[int, object] = {} 230 | spaces: dict[str, list[Project]] = {} 231 | for project in self.projects: 232 | spaces.setdefault(project.space, []).append(project) 233 | for space in sorted(spaces.keys(), key=lambda item: item.lower()): 234 | space_node = tree.root.add(space, data=TreeItem.space(space), expand=True) 235 | space_nodes[space] = space_node 236 | for project in sorted(spaces[space], key=lambda item: item.name.lower()): 237 | project_node = space_node.add(project.name, data=TreeItem.project(project.id)) 238 | project_nodes[project.id] = project_node 239 | 240 | if previous_project in project_nodes: 241 | tree.move_cursor(project_nodes[previous_project]) 242 | self.set_selected_project(previous_project) 243 | return 244 | if previous_space in space_nodes: 245 | tree.move_cursor(space_nodes[previous_space]) 246 | self.set_selected_space(previous_space) 247 | return 248 | if project_nodes: 249 | first_project_id = next(iter(project_nodes.keys())) 250 | tree.move_cursor(project_nodes[first_project_id]) 251 | self.set_selected_project(first_project_id) 252 | return 253 | if space_nodes: 254 | first_space = next(iter(space_nodes.keys())) 255 | tree.move_cursor(space_nodes[first_space]) 256 | self.set_selected_space(first_space) 257 | return 258 | self.set_selected_project(None) 259 | 260 | def set_selected_project(self, project_id: int | None) -> None: 261 | self.selected_project_id = project_id 262 | self.selected_space = None 263 | project = self.project_map.get(project_id) if project_id is not None else None 264 | self.update_detail(project) 265 | if project is not None: 266 | self._update_terminal_cwd(project.path) 267 | 268 | def set_selected_space(self, space: str | None) -> None: 269 | self.selected_space = space 270 | self.selected_project_id = None 271 | if space is None: 272 | self.update_detail(None) 273 | return 274 | self._render_space_detail(space) 275 | 276 | def update_detail(self, project: Project | None) -> None: 277 | detail = self.query_one("#detail", Static) 278 | if project is None: 279 | detail.update("未选择项目。") 280 | return 281 | tags = ", ".join(project.tags) if project.tags else "-" 282 | last_opened = project.last_opened_at or "-" 283 | detail.update( 284 | "\n".join( 285 | [ 286 | project.name, 287 | "", 288 | f"路径:{project.path}", 289 | f"空间:{project.space}", 290 | f"标签:{tags}", 291 | f"上次打开:{last_opened}", 292 | f"创建时间:{project.created_at}", 293 | f"更新时间:{project.updated_at}", 294 | ] 295 | ) 296 | ) 297 | 298 | def _render_space_detail(self, space: str) -> None: 299 | detail = self.query_one("#detail", Static) 300 | count = self.space_counts.get(space, 0) 301 | header = Text(f"空间:{space}", style="bold") 302 | meta = Text( 303 | "\n".join( 304 | [ 305 | f"项目数量:{count}", 306 | ] 307 | ) 308 | ) 309 | projects = [project for project in self.projects if project.space == space] 310 | if projects: 311 | cards = [] 312 | for project in projects: 313 | tags = "、".join(project.tags) if project.tags else "无" 314 | body = Text(f"标签:{tags}", style="dim") 315 | cards.append(Panel(body, title=project.name, title_align="center", padding=(0, 1))) 316 | grid = Columns(cards, equal=True, expand=True, padding=(0, 1)) 317 | else: 318 | grid = Text("当前空间暂无项目。") 319 | detail.update(Group(header, Text(""), meta, Text(""), grid)) 320 | 321 | def get_selected_project(self) -> Project | None: 322 | if self.selected_project_id is None: 323 | return None 324 | return self.project_map.get(self.selected_project_id) 325 | 326 | def _handle_tree_selection(self, data: object) -> None: 327 | if isinstance(data, TreeItem) and data.kind == "project": 328 | self.set_selected_project(int(data.value)) 329 | return 330 | if isinstance(data, TreeItem) and data.kind == "space": 331 | self.set_selected_space(str(data.value)) 332 | return 333 | self.set_selected_project(None) 334 | 335 | def _handle_add_result(self, result: ProjectFormResult | None) -> None: 336 | if result is None: 337 | return 338 | self.repository.create_project(result.name, result.path, result.space, result.tags) 339 | self.refresh_projects(self.query_text) 340 | 341 | def _handle_edit_result(self, project_id: int, result: ProjectFormResult | None) -> None: 342 | if result is None: 343 | return 344 | self.repository.update_project(project_id, result.name, result.path, result.space, result.tags) 345 | self.refresh_projects(self.query_text) 346 | 347 | def _handle_delete_result(self, project_id: int, confirmed: bool) -> None: 348 | if not confirmed: 349 | return 350 | self.repository.delete_project(project_id) 351 | self.refresh_projects(self.query_text) 352 | 353 | def _handle_import_result(self, result: ImportDirectoryResult | None) -> None: 354 | if result is None: 355 | return 356 | parent = Path(result.path).expanduser().resolve() 357 | try: 358 | summary = import_projects( 359 | repository=self.repository, 360 | parent_path=parent, 361 | space=result.space, 362 | tags=result.tags, 363 | ) 364 | except FileNotFoundError: 365 | self.push_screen(MessageScreen("导入失败", "父目录不存在或不可访问。")) 366 | return 367 | if summary.total_children == 0: 368 | self.push_screen(MessageScreen("导入完成", "未找到子目录。")) 369 | return 370 | self.refresh_projects(self.query_text) 371 | message = ( 372 | f"新增 {summary.added} 个项目,跳过 {summary.skipped_existing} 个已存在项目," 373 | f"跳过 {summary.skipped_hidden} 个隐藏目录。" 374 | ) 375 | self.push_screen(MessageScreen("导入完成", message)) 376 | --------------------------------------------------------------------------------