ccmd 2.0.0__tar.gz → 2.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ccmd-2.0.0/ccmd.egg-info → ccmd-2.0.1}/PKG-INFO +1 -1
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/__init__.py +1 -1
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/cli.py +18 -4
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/config/schema.py +1 -1
- ccmd-2.0.1/ccmd/core/fs.py +141 -0
- ccmd-2.0.1/ccmd/tui/screens/browser.py +340 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/screens/hub.py +11 -1
- {ccmd-2.0.0 → ccmd-2.0.1/ccmd.egg-info}/PKG-INFO +1 -1
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd.egg-info/SOURCES.txt +2 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/pyproject.toml +1 -1
- {ccmd-2.0.0 → ccmd-2.0.1}/LICENSE +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/MANIFEST.in +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/README.md +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/__main__.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/config/__init__.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/config/paths.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/__init__.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/__init__.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/aider.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/base.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/chatgpt.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/claude.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/codex.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/cursor.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/goose.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/grok.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/launcher.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/migrate.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/profiles.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/projects.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/scan.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/sessions.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/ssh.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/mcp/__init__.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/mcp/server.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/__init__.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/app.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/ascii.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/screens/__init__.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/screens/agents.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/screens/aliases.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/screens/project_edit.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/screens/sessions.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/screens/ssh.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/theme.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/widgets/__init__.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/widgets/spinner.py +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd.egg-info/dependency_links.txt +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd.egg-info/entry_points.txt +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd.egg-info/requires.txt +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/ccmd.egg-info/top_level.txt +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/commands.yaml +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/requirements.txt +0 -0
- {ccmd-2.0.0 → ccmd-2.0.1}/setup.cfg +0 -0
|
@@ -6,7 +6,7 @@ import argparse
|
|
|
6
6
|
import json
|
|
7
7
|
import shutil
|
|
8
8
|
import sys
|
|
9
|
-
from pathlib import Path
|
|
9
|
+
from pathlib import Path # noqa: F401 — used by project browse/add
|
|
10
10
|
|
|
11
11
|
from ccmd import __version__
|
|
12
12
|
from ccmd.config.paths import ensure_dirs, home
|
|
@@ -118,7 +118,21 @@ def _cmd_project(args: argparse.Namespace) -> int:
|
|
|
118
118
|
print(f"{r['name']:<24} {r['path']} ({', '.join(r['markers'])})")
|
|
119
119
|
print(f"{len(rows)} project-like dirs" + (" (registered new)" if args.register else ""))
|
|
120
120
|
return 0
|
|
121
|
-
|
|
121
|
+
if args.project_cmd == "browse":
|
|
122
|
+
from ccmd.tui.app import CcmdApp
|
|
123
|
+
from ccmd.tui.screens.browser import DirBrowserScreen
|
|
124
|
+
|
|
125
|
+
class _BrowseApp(CcmdApp):
|
|
126
|
+
def on_mount(self) -> None:
|
|
127
|
+
def _done(ok: bool | None) -> None:
|
|
128
|
+
self.exit(0 if ok else 1)
|
|
129
|
+
|
|
130
|
+
start = Path(args.path).expanduser() if args.path else None
|
|
131
|
+
self.push_screen(DirBrowserScreen(start=start), _done)
|
|
132
|
+
|
|
133
|
+
_BrowseApp().run()
|
|
134
|
+
return 0
|
|
135
|
+
print("usage: ccmd project add|rm|scan|browse", file=sys.stderr)
|
|
122
136
|
return 1
|
|
123
137
|
|
|
124
138
|
|
|
@@ -214,9 +228,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
214
228
|
mig.add_argument("--apply", action="store_true")
|
|
215
229
|
|
|
216
230
|
proj = sub.add_parser("project", help="Manage projects")
|
|
217
|
-
proj.add_argument("project_cmd", choices=["add", "rm", "scan"])
|
|
231
|
+
proj.add_argument("project_cmd", choices=["add", "rm", "scan", "browse"])
|
|
218
232
|
proj.add_argument("--name")
|
|
219
|
-
proj.add_argument("--path")
|
|
233
|
+
proj.add_argument("--path", help="browse start path, or add path")
|
|
220
234
|
proj.add_argument("--agent")
|
|
221
235
|
proj.add_argument("--register", action="store_true", help="scan: add found projects")
|
|
222
236
|
|
|
@@ -20,7 +20,7 @@ class UIConfig:
|
|
|
20
20
|
@dataclass
|
|
21
21
|
class Config:
|
|
22
22
|
scan_roots: list[str] = field(
|
|
23
|
-
default_factory=lambda: ["~/projects", "~/code", "/mnt
|
|
23
|
+
default_factory=lambda: ["~/projects", "~/code", "~/dev", "/mnt"]
|
|
24
24
|
)
|
|
25
25
|
scan_depth: int = 3
|
|
26
26
|
default_agent: str = "claude"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Filesystem helpers for browse / scan roots (no machine-specific hardcoding)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ccmd.config.schema import load_config
|
|
8
|
+
|
|
9
|
+
# Never show these as browsable children (noise / huge trees)
|
|
10
|
+
SKIP_DIR_NAMES = frozenset(
|
|
11
|
+
{
|
|
12
|
+
".git",
|
|
13
|
+
".svn",
|
|
14
|
+
".hg",
|
|
15
|
+
"node_modules",
|
|
16
|
+
"__pycache__",
|
|
17
|
+
".venv",
|
|
18
|
+
"venv",
|
|
19
|
+
"dist",
|
|
20
|
+
"build",
|
|
21
|
+
"target",
|
|
22
|
+
".tox",
|
|
23
|
+
".mypy_cache",
|
|
24
|
+
".pytest_cache",
|
|
25
|
+
".ruff_cache",
|
|
26
|
+
"site-packages",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_browse_roots() -> list[Path]:
|
|
32
|
+
"""Portable roots: home, config scan_roots, and any existing /mnt/* tops."""
|
|
33
|
+
roots: list[Path] = []
|
|
34
|
+
seen: set[str] = set()
|
|
35
|
+
|
|
36
|
+
def add(p: Path) -> None:
|
|
37
|
+
try:
|
|
38
|
+
r = p.expanduser().resolve()
|
|
39
|
+
except OSError:
|
|
40
|
+
return
|
|
41
|
+
if not r.is_dir():
|
|
42
|
+
return
|
|
43
|
+
key = str(r)
|
|
44
|
+
if key in seen:
|
|
45
|
+
return
|
|
46
|
+
seen.add(key)
|
|
47
|
+
roots.append(r)
|
|
48
|
+
|
|
49
|
+
add(Path.home())
|
|
50
|
+
add(Path.home() / "projects")
|
|
51
|
+
add(Path.home() / "code")
|
|
52
|
+
add(Path.home() / "dev")
|
|
53
|
+
add(Path.home() / "src")
|
|
54
|
+
add(Path.cwd())
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
cfg = load_config()
|
|
58
|
+
for s in cfg.scan_roots:
|
|
59
|
+
add(Path(s))
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
# Any mount under /mnt (WSL drives, shared volumes) — not just one folder
|
|
64
|
+
mnt = Path("/mnt")
|
|
65
|
+
if mnt.is_dir():
|
|
66
|
+
add(mnt)
|
|
67
|
+
try:
|
|
68
|
+
for child in sorted(mnt.iterdir()):
|
|
69
|
+
if child.is_dir() and not child.name.startswith("."):
|
|
70
|
+
add(child)
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
# Common Unix work locations
|
|
75
|
+
for p in (Path("/opt"), Path("/srv"), Path("/var/www")):
|
|
76
|
+
add(p)
|
|
77
|
+
|
|
78
|
+
return roots
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def list_dir_entries(
|
|
82
|
+
path: Path,
|
|
83
|
+
*,
|
|
84
|
+
show_hidden: bool = False,
|
|
85
|
+
dirs_only: bool = True,
|
|
86
|
+
) -> list[tuple[str, Path, bool]]:
|
|
87
|
+
"""Return sorted (label, path, is_dir) for children of path."""
|
|
88
|
+
path = path.expanduser()
|
|
89
|
+
try:
|
|
90
|
+
path = path.resolve()
|
|
91
|
+
except OSError:
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
entries: list[tuple[str, Path, bool]] = []
|
|
95
|
+
try:
|
|
96
|
+
children = list(path.iterdir())
|
|
97
|
+
except OSError:
|
|
98
|
+
return []
|
|
99
|
+
|
|
100
|
+
for child in children:
|
|
101
|
+
name = child.name
|
|
102
|
+
if not show_hidden and name.startswith("."):
|
|
103
|
+
continue
|
|
104
|
+
if name in SKIP_DIR_NAMES:
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
is_dir = child.is_dir()
|
|
108
|
+
except OSError:
|
|
109
|
+
continue
|
|
110
|
+
if dirs_only and not is_dir:
|
|
111
|
+
continue
|
|
112
|
+
# skip symlinks that loop / broken
|
|
113
|
+
if child.is_symlink():
|
|
114
|
+
try:
|
|
115
|
+
if not child.resolve().exists():
|
|
116
|
+
continue
|
|
117
|
+
except OSError:
|
|
118
|
+
continue
|
|
119
|
+
entries.append((name, child, is_dir))
|
|
120
|
+
|
|
121
|
+
entries.sort(key=lambda t: (not t[2], t[0].lower()))
|
|
122
|
+
return entries
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_projectish(path: Path) -> bool:
|
|
126
|
+
markers = {
|
|
127
|
+
".git",
|
|
128
|
+
"package.json",
|
|
129
|
+
"pyproject.toml",
|
|
130
|
+
"Cargo.toml",
|
|
131
|
+
"go.mod",
|
|
132
|
+
"CLAUDE.md",
|
|
133
|
+
"AGENTS.md",
|
|
134
|
+
"Makefile",
|
|
135
|
+
"composer.json",
|
|
136
|
+
}
|
|
137
|
+
try:
|
|
138
|
+
names = {p.name for p in path.iterdir()}
|
|
139
|
+
except OSError:
|
|
140
|
+
return False
|
|
141
|
+
return bool(names & markers)
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Interactive directory browser — pick any folder as a project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from textual import on
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Horizontal, Vertical
|
|
11
|
+
from textual.screen import Screen
|
|
12
|
+
from textual.widgets import Footer, Input, OptionList, Static
|
|
13
|
+
from textual.widgets.option_list import Option
|
|
14
|
+
|
|
15
|
+
from ccmd.config.schema import load_config
|
|
16
|
+
from ccmd.core.fs import default_browse_roots, is_projectish, list_dir_entries
|
|
17
|
+
from ccmd.core.projects import ProjectRegistry
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DirBrowserScreen(Screen[bool | None]):
|
|
21
|
+
"""Browse the filesystem; select a directory to register as a project.
|
|
22
|
+
|
|
23
|
+
Keys:
|
|
24
|
+
enter open directory / enter root
|
|
25
|
+
backspace go up
|
|
26
|
+
s / a select highlighted (or current) dir as project
|
|
27
|
+
r jump to roots list
|
|
28
|
+
. toggle hidden
|
|
29
|
+
/ focus filter
|
|
30
|
+
esc cancel
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
BINDINGS = [
|
|
34
|
+
Binding("escape", "cancel", "Back", show=True),
|
|
35
|
+
Binding("enter", "open_or_select", "Open", show=True),
|
|
36
|
+
Binding("backspace", "up", "Up", show=True),
|
|
37
|
+
Binding("s", "select", "Select", show=True),
|
|
38
|
+
Binding("a", "select", "Add", show=False),
|
|
39
|
+
Binding("r", "roots", "Roots", show=True),
|
|
40
|
+
Binding("period", "toggle_hidden", "Hidden", show=True),
|
|
41
|
+
Binding("slash", "focus_filter", "Filter", show=False),
|
|
42
|
+
Binding("/", "focus_filter", "Filter", show=True),
|
|
43
|
+
Binding("g", "go_path", "Go path", show=True),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
def __init__(self, start: Path | None = None) -> None:
|
|
47
|
+
super().__init__()
|
|
48
|
+
self._mode: str = "browse" # browse | roots | goto
|
|
49
|
+
self._cwd: Path = (start or Path.home()).expanduser()
|
|
50
|
+
try:
|
|
51
|
+
self._cwd = self._cwd.resolve()
|
|
52
|
+
except OSError:
|
|
53
|
+
self._cwd = Path.home()
|
|
54
|
+
self._show_hidden = False
|
|
55
|
+
self._entries: list[tuple[str, Path, bool]] = [] # label, path, is_dir
|
|
56
|
+
self._filter = ""
|
|
57
|
+
self._agent = load_config().default_agent or "claude"
|
|
58
|
+
|
|
59
|
+
def compose(self) -> ComposeResult:
|
|
60
|
+
yield Static("directory browser", id="subtitle")
|
|
61
|
+
yield Static("", id="path-bar")
|
|
62
|
+
yield Input(placeholder="filter this folder…", id="filter")
|
|
63
|
+
with Horizontal(id="main"):
|
|
64
|
+
with Vertical(id="projects-panel"):
|
|
65
|
+
yield Static("entries", classes="panel-title", id="list-title")
|
|
66
|
+
yield OptionList(id="dir-list")
|
|
67
|
+
with Vertical(id="detail-panel"):
|
|
68
|
+
yield Static("preview", classes="panel-title")
|
|
69
|
+
yield Static("", id="preview")
|
|
70
|
+
yield Static(
|
|
71
|
+
"↵ open · ⌫ up · s select as project · r roots · g go path · . hidden · esc back",
|
|
72
|
+
id="footer-bar",
|
|
73
|
+
)
|
|
74
|
+
yield Footer()
|
|
75
|
+
|
|
76
|
+
def on_mount(self) -> None:
|
|
77
|
+
self._render_list()
|
|
78
|
+
self.query_one("#dir-list", OptionList).focus()
|
|
79
|
+
|
|
80
|
+
# ── listing ──────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
def _set_path_bar(self, extra: str = "") -> None:
|
|
83
|
+
bar = self.query_one("#path-bar", Static)
|
|
84
|
+
mark = " · project?" if is_projectish(self._cwd) else ""
|
|
85
|
+
hidden = " · hidden" if self._show_hidden else ""
|
|
86
|
+
bar.update(
|
|
87
|
+
f"[bold #00ffc8]{self._cwd}[/]{mark}{hidden}"
|
|
88
|
+
+ (f" [#6a6a80]{extra}[/]" if extra else "")
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def _render_list(self) -> None:
|
|
92
|
+
if self._mode == "roots":
|
|
93
|
+
self._render_roots()
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
self._set_path_bar()
|
|
97
|
+
self.query_one("#list-title", Static).update("directories")
|
|
98
|
+
ol = self.query_one("#dir-list", OptionList)
|
|
99
|
+
ol.clear_options()
|
|
100
|
+
|
|
101
|
+
entries = list_dir_entries(
|
|
102
|
+
self._cwd, show_hidden=self._show_hidden, dirs_only=True
|
|
103
|
+
)
|
|
104
|
+
q = self._filter.strip().lower()
|
|
105
|
+
if q:
|
|
106
|
+
entries = [e for e in entries if q in e[0].lower()]
|
|
107
|
+
|
|
108
|
+
self._entries = []
|
|
109
|
+
# parent always first when not at filesystem root
|
|
110
|
+
if self._cwd.parent != self._cwd:
|
|
111
|
+
self._entries.append(("..", self._cwd.parent, True))
|
|
112
|
+
ol.add_option(Option(" ../", id="__parent__"))
|
|
113
|
+
|
|
114
|
+
if not entries and not self._entries:
|
|
115
|
+
ol.add_option(Option(" (empty)", id="__empty__"))
|
|
116
|
+
self._update_preview(None)
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
for name, path, is_dir in entries:
|
|
120
|
+
self._entries.append((name, path, is_dir))
|
|
121
|
+
badge = "◆" if is_projectish(path) else "·"
|
|
122
|
+
ol.add_option(Option(f" {badge} {name}/", id=str(path)))
|
|
123
|
+
|
|
124
|
+
ol.highlighted = 0
|
|
125
|
+
self._update_preview(self._selected_path())
|
|
126
|
+
|
|
127
|
+
def _render_roots(self) -> None:
|
|
128
|
+
self._mode = "roots"
|
|
129
|
+
self.query_one("#path-bar", Static).update(
|
|
130
|
+
"[bold #c84bff]browse roots[/] · pick a starting point"
|
|
131
|
+
)
|
|
132
|
+
self.query_one("#list-title", Static).update("roots")
|
|
133
|
+
ol = self.query_one("#dir-list", OptionList)
|
|
134
|
+
ol.clear_options()
|
|
135
|
+
self._entries = []
|
|
136
|
+
for root in default_browse_roots():
|
|
137
|
+
self._entries.append((root.name or str(root), root, True))
|
|
138
|
+
label = str(root)
|
|
139
|
+
badge = "◆" if is_projectish(root) else "·"
|
|
140
|
+
ol.add_option(Option(f" {badge} {label}", id=str(root)))
|
|
141
|
+
if not self._entries:
|
|
142
|
+
ol.add_option(Option(" (no roots)", id="__empty__"))
|
|
143
|
+
ol.highlighted = 0
|
|
144
|
+
self._update_preview(self._selected_path())
|
|
145
|
+
|
|
146
|
+
def _selected_path(self) -> Path | None:
|
|
147
|
+
ol = self.query_one("#dir-list", OptionList)
|
|
148
|
+
if ol.highlighted is None:
|
|
149
|
+
return None
|
|
150
|
+
try:
|
|
151
|
+
opt = ol.get_option_at_index(ol.highlighted)
|
|
152
|
+
except Exception:
|
|
153
|
+
return None
|
|
154
|
+
if opt.id in (None, "__empty__"):
|
|
155
|
+
return None
|
|
156
|
+
if opt.id == "__parent__":
|
|
157
|
+
return self._cwd.parent
|
|
158
|
+
return Path(str(opt.id))
|
|
159
|
+
|
|
160
|
+
def _update_preview(self, path: Path | None) -> None:
|
|
161
|
+
prev = self.query_one("#preview", Static)
|
|
162
|
+
if path is None:
|
|
163
|
+
prev.update("—")
|
|
164
|
+
return
|
|
165
|
+
try:
|
|
166
|
+
resolved = path.resolve()
|
|
167
|
+
except OSError:
|
|
168
|
+
resolved = path
|
|
169
|
+
kids = list_dir_entries(resolved, show_hidden=self._show_hidden, dirs_only=True)
|
|
170
|
+
project = is_projectish(resolved)
|
|
171
|
+
lines = [
|
|
172
|
+
f"[bold #00ffc8]{resolved.name or resolved}[/]",
|
|
173
|
+
f"path {resolved}",
|
|
174
|
+
f"project {'yes ◆' if project else 'no (still selectable)'}",
|
|
175
|
+
f"subdirs {len(kids)}",
|
|
176
|
+
"",
|
|
177
|
+
"[#6a6a80]children[/]",
|
|
178
|
+
]
|
|
179
|
+
for name, _, _ in kids[:12]:
|
|
180
|
+
lines.append(f" · {name}/")
|
|
181
|
+
if len(kids) > 12:
|
|
182
|
+
lines.append(f" … +{len(kids) - 12} more")
|
|
183
|
+
lines.extend(
|
|
184
|
+
[
|
|
185
|
+
"",
|
|
186
|
+
"[#6a6a80]↵ open folder · s select as project[/]",
|
|
187
|
+
]
|
|
188
|
+
)
|
|
189
|
+
prev.update("\n".join(lines))
|
|
190
|
+
|
|
191
|
+
# ── events ───────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
@on(Input.Changed, "#filter")
|
|
194
|
+
def on_filter(self, event: Input.Changed) -> None:
|
|
195
|
+
self._filter = event.value
|
|
196
|
+
if self._mode != "roots":
|
|
197
|
+
self._render_list()
|
|
198
|
+
|
|
199
|
+
@on(OptionList.OptionHighlighted)
|
|
200
|
+
def on_highlight(self, event: OptionList.OptionHighlighted) -> None:
|
|
201
|
+
self._update_preview(self._selected_path())
|
|
202
|
+
|
|
203
|
+
@on(OptionList.OptionSelected)
|
|
204
|
+
def on_select_event(self, event: OptionList.OptionSelected) -> None:
|
|
205
|
+
self.action_open_or_select()
|
|
206
|
+
|
|
207
|
+
# ── actions ──────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
def action_focus_filter(self) -> None:
|
|
210
|
+
self.query_one("#filter", Input).focus()
|
|
211
|
+
|
|
212
|
+
def action_toggle_hidden(self) -> None:
|
|
213
|
+
self._show_hidden = not self._show_hidden
|
|
214
|
+
self._render_list()
|
|
215
|
+
|
|
216
|
+
def action_roots(self) -> None:
|
|
217
|
+
self._filter = ""
|
|
218
|
+
self.query_one("#filter", Input).value = ""
|
|
219
|
+
self._render_roots()
|
|
220
|
+
|
|
221
|
+
def action_up(self) -> None:
|
|
222
|
+
if self._mode == "roots":
|
|
223
|
+
return
|
|
224
|
+
parent = self._cwd.parent
|
|
225
|
+
if parent == self._cwd:
|
|
226
|
+
self.notify("already at filesystem root", severity="warning")
|
|
227
|
+
return
|
|
228
|
+
self._cwd = parent
|
|
229
|
+
self._filter = ""
|
|
230
|
+
self.query_one("#filter", Input).value = ""
|
|
231
|
+
self._render_list()
|
|
232
|
+
|
|
233
|
+
def action_open_or_select(self) -> None:
|
|
234
|
+
path = self._selected_path()
|
|
235
|
+
if path is None:
|
|
236
|
+
return
|
|
237
|
+
if self._mode == "roots":
|
|
238
|
+
self._mode = "browse"
|
|
239
|
+
self._cwd = path
|
|
240
|
+
self._filter = ""
|
|
241
|
+
self.query_one("#filter", Input).value = ""
|
|
242
|
+
self._render_list()
|
|
243
|
+
return
|
|
244
|
+
# open into directory
|
|
245
|
+
if path.is_dir():
|
|
246
|
+
self._cwd = path
|
|
247
|
+
self._filter = ""
|
|
248
|
+
self.query_one("#filter", Input).value = ""
|
|
249
|
+
self._render_list()
|
|
250
|
+
|
|
251
|
+
def action_select(self) -> None:
|
|
252
|
+
"""Register highlighted path, or current directory if nothing useful."""
|
|
253
|
+
path = self._selected_path()
|
|
254
|
+
# if on .. or empty, use cwd
|
|
255
|
+
if path is None or (path == self._cwd.parent and self._mode == "browse"):
|
|
256
|
+
path = self._cwd
|
|
257
|
+
if self._mode == "roots" and path:
|
|
258
|
+
# selecting a root without entering — still ok as project
|
|
259
|
+
pass
|
|
260
|
+
if not path or not path.is_dir():
|
|
261
|
+
self.notify("not a directory", severity="error")
|
|
262
|
+
return
|
|
263
|
+
try:
|
|
264
|
+
resolved = path.resolve()
|
|
265
|
+
except OSError as e:
|
|
266
|
+
self.notify(str(e), severity="error")
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
reg = ProjectRegistry()
|
|
270
|
+
existing = reg.find_by_path(resolved)
|
|
271
|
+
if existing:
|
|
272
|
+
self.notify(f"already registered as {existing.id}", severity="warning")
|
|
273
|
+
self.dismiss(True)
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
name = resolved.name or str(resolved)
|
|
277
|
+
p = reg.add(
|
|
278
|
+
name=name,
|
|
279
|
+
path=resolved,
|
|
280
|
+
default_agent=self._agent,
|
|
281
|
+
notes="added via browser",
|
|
282
|
+
)
|
|
283
|
+
self.notify(f"added {p.id} → {resolved}", severity="information")
|
|
284
|
+
self.dismiss(True)
|
|
285
|
+
|
|
286
|
+
def action_go_path(self) -> None:
|
|
287
|
+
"""Type an absolute/relative path and jump there."""
|
|
288
|
+
self.app.push_screen(_GoPathModal(self._cwd), self._after_go)
|
|
289
|
+
|
|
290
|
+
def _after_go(self, result: str | None) -> None:
|
|
291
|
+
if not result:
|
|
292
|
+
return
|
|
293
|
+
path = Path(result).expanduser()
|
|
294
|
+
if not path.is_dir():
|
|
295
|
+
self.notify(f"not a directory: {result}", severity="error")
|
|
296
|
+
return
|
|
297
|
+
try:
|
|
298
|
+
self._cwd = path.resolve()
|
|
299
|
+
except OSError:
|
|
300
|
+
self._cwd = path
|
|
301
|
+
self._mode = "browse"
|
|
302
|
+
self._filter = ""
|
|
303
|
+
self.query_one("#filter", Input).value = ""
|
|
304
|
+
self._render_list()
|
|
305
|
+
|
|
306
|
+
def action_cancel(self) -> None:
|
|
307
|
+
self.dismiss(False)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class _GoPathModal(Screen[str | None]):
|
|
311
|
+
"""Minimal path jump prompt."""
|
|
312
|
+
|
|
313
|
+
BINDINGS = [
|
|
314
|
+
Binding("escape", "cancel", "Cancel", show=True),
|
|
315
|
+
Binding("enter", "submit", "Go", show=True),
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
def __init__(self, cwd: Path) -> None:
|
|
319
|
+
super().__init__()
|
|
320
|
+
self._cwd = cwd
|
|
321
|
+
|
|
322
|
+
def compose(self) -> ComposeResult:
|
|
323
|
+
yield Static("go to path", id="subtitle")
|
|
324
|
+
yield Input(value=str(self._cwd), id="path-input", placeholder="/home/you/project")
|
|
325
|
+
yield Static("enter jump · esc cancel", id="footer-bar")
|
|
326
|
+
|
|
327
|
+
def on_mount(self) -> None:
|
|
328
|
+
inp = self.query_one("#path-input", Input)
|
|
329
|
+
inp.focus()
|
|
330
|
+
inp.cursor_position = len(inp.value)
|
|
331
|
+
|
|
332
|
+
def action_submit(self) -> None:
|
|
333
|
+
self.dismiss(self.query_one("#path-input", Input).value.strip())
|
|
334
|
+
|
|
335
|
+
def action_cancel(self) -> None:
|
|
336
|
+
self.dismiss(None)
|
|
337
|
+
|
|
338
|
+
@on(Input.Submitted, "#path-input")
|
|
339
|
+
def on_submit(self, event: Input.Submitted) -> None:
|
|
340
|
+
self.dismiss(event.value.strip())
|
|
@@ -22,6 +22,7 @@ class HubScreen(Screen):
|
|
|
22
22
|
Binding("enter", "open_project", "Open", show=True),
|
|
23
23
|
Binding("a", "pick_agent", "Agents", show=True),
|
|
24
24
|
Binding("n", "new_project", "New", show=True),
|
|
25
|
+
Binding("b", "browse", "Browse", show=True),
|
|
25
26
|
Binding("s", "sessions", "Sessions", show=True),
|
|
26
27
|
Binding("h", "ssh", "SSH", show=True),
|
|
27
28
|
Binding("l", "aliases", "Aliases", show=True),
|
|
@@ -50,7 +51,7 @@ class HubScreen(Screen):
|
|
|
50
51
|
yield Static("Select a project", id="detail")
|
|
51
52
|
yield Static("", id="status")
|
|
52
53
|
yield Static(
|
|
53
|
-
"↵ open · a agents · n new · s sessions · h ssh · l aliases · / filter · q quit",
|
|
54
|
+
"↵ open · a agents · b browse · n new · s sessions · h ssh · l aliases · / filter · q quit",
|
|
54
55
|
id="footer-bar",
|
|
55
56
|
)
|
|
56
57
|
yield Footer()
|
|
@@ -176,6 +177,15 @@ class HubScreen(Screen):
|
|
|
176
177
|
|
|
177
178
|
self.app.push_screen(ProjectEditScreen(), _done)
|
|
178
179
|
|
|
180
|
+
def action_browse(self) -> None:
|
|
181
|
+
from ccmd.tui.screens.browser import DirBrowserScreen
|
|
182
|
+
|
|
183
|
+
def _done(ok: bool | None) -> None:
|
|
184
|
+
if ok:
|
|
185
|
+
self._load_projects(self.query_one("#filter", Input).value)
|
|
186
|
+
|
|
187
|
+
self.app.push_screen(DirBrowserScreen(), _done)
|
|
188
|
+
|
|
179
189
|
def action_sessions(self) -> None:
|
|
180
190
|
from ccmd.tui.screens.sessions import SessionsScreen
|
|
181
191
|
|
|
@@ -17,6 +17,7 @@ ccmd/config/__init__.py
|
|
|
17
17
|
ccmd/config/paths.py
|
|
18
18
|
ccmd/config/schema.py
|
|
19
19
|
ccmd/core/__init__.py
|
|
20
|
+
ccmd/core/fs.py
|
|
20
21
|
ccmd/core/launcher.py
|
|
21
22
|
ccmd/core/migrate.py
|
|
22
23
|
ccmd/core/profiles.py
|
|
@@ -42,6 +43,7 @@ ccmd/tui/theme.py
|
|
|
42
43
|
ccmd/tui/screens/__init__.py
|
|
43
44
|
ccmd/tui/screens/agents.py
|
|
44
45
|
ccmd/tui/screens/aliases.py
|
|
46
|
+
ccmd/tui/screens/browser.py
|
|
45
47
|
ccmd/tui/screens/hub.py
|
|
46
48
|
ccmd/tui/screens/project_edit.py
|
|
47
49
|
ccmd/tui/screens/sessions.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|