forestui 0.9.0__py3-none-any.whl
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.
- forestui/__init__.py +3 -0
- forestui/__main__.py +6 -0
- forestui/app.py +1012 -0
- forestui/cli.py +169 -0
- forestui/components/__init__.py +21 -0
- forestui/components/messages.py +76 -0
- forestui/components/modals.py +668 -0
- forestui/components/repository_detail.py +377 -0
- forestui/components/sidebar.py +256 -0
- forestui/components/worktree_detail.py +326 -0
- forestui/models.py +221 -0
- forestui/services/__init__.py +16 -0
- forestui/services/claude_session.py +179 -0
- forestui/services/git.py +254 -0
- forestui/services/github.py +242 -0
- forestui/services/settings.py +84 -0
- forestui/services/tmux.py +320 -0
- forestui/state.py +248 -0
- forestui/theme.py +657 -0
- forestui-0.9.0.dist-info/METADATA +152 -0
- forestui-0.9.0.dist-info/RECORD +23 -0
- forestui-0.9.0.dist-info/WHEEL +4 -0
- forestui-0.9.0.dist-info/entry_points.txt +2 -0
forestui/cli.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Command-line interface for forestui."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from forestui import __version__
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_window_name(dev_mode: bool = False) -> str:
|
|
16
|
+
"""Get the tmux window name based on dev mode flag."""
|
|
17
|
+
if dev_mode:
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
|
|
20
|
+
hhmm = datetime.now().strftime("%H%M")
|
|
21
|
+
return f"forestui-dev-{hhmm}"
|
|
22
|
+
return "forestui"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def rename_tmux_window(name: str) -> None:
|
|
26
|
+
"""Rename the current tmux window."""
|
|
27
|
+
from forestui.services.tmux import get_tmux_service
|
|
28
|
+
|
|
29
|
+
get_tmux_service().rename_window(name)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def slugify(text: str) -> str:
|
|
33
|
+
"""Convert text to a safe slug for tmux session names."""
|
|
34
|
+
import re
|
|
35
|
+
|
|
36
|
+
# Convert to lowercase and replace spaces/special chars with dashes
|
|
37
|
+
slug = re.sub(r"[^a-zA-Z0-9]+", "-", text.lower())
|
|
38
|
+
# Remove leading/trailing dashes
|
|
39
|
+
return slug.strip("-")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ensure_tmux(
|
|
43
|
+
forest_path: str | None,
|
|
44
|
+
debug_mode: bool = False,
|
|
45
|
+
no_self_update: bool = False,
|
|
46
|
+
dev_mode: bool = False,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Ensure forestui is running inside tmux, or exec into tmux."""
|
|
49
|
+
# Already inside tmux - good to go
|
|
50
|
+
if os.environ.get("TMUX"):
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
# Check if tmux is available
|
|
54
|
+
tmux_path = shutil.which("tmux")
|
|
55
|
+
if not tmux_path:
|
|
56
|
+
click.echo("Error: forestui requires tmux to be installed.", err=True)
|
|
57
|
+
click.echo("", err=True)
|
|
58
|
+
click.echo("Install tmux:", err=True)
|
|
59
|
+
click.echo(" macOS: brew install tmux", err=True)
|
|
60
|
+
click.echo(" Ubuntu: sudo apt install tmux", err=True)
|
|
61
|
+
click.echo(" Fedora: sudo dnf install tmux", err=True)
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
|
|
64
|
+
# Determine forest folder name for session naming
|
|
65
|
+
if forest_path:
|
|
66
|
+
forest_folder = Path(forest_path).expanduser().resolve().name
|
|
67
|
+
else:
|
|
68
|
+
forest_folder = "forest" # default ~/forest
|
|
69
|
+
|
|
70
|
+
session_name = f"forestui-{slugify(forest_folder)}"
|
|
71
|
+
|
|
72
|
+
# Build the forestui command with arguments
|
|
73
|
+
forestui_cmd = "forestui"
|
|
74
|
+
if debug_mode:
|
|
75
|
+
forestui_cmd += " --debug"
|
|
76
|
+
if no_self_update:
|
|
77
|
+
forestui_cmd += " --no-self-update"
|
|
78
|
+
if dev_mode:
|
|
79
|
+
forestui_cmd += " --dev"
|
|
80
|
+
if forest_path:
|
|
81
|
+
forestui_cmd += f" {forest_path}"
|
|
82
|
+
|
|
83
|
+
# Check if session already exists
|
|
84
|
+
import subprocess
|
|
85
|
+
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
["tmux", "has-session", "-t", session_name],
|
|
88
|
+
capture_output=True,
|
|
89
|
+
)
|
|
90
|
+
session_exists = result.returncode == 0
|
|
91
|
+
|
|
92
|
+
if session_exists:
|
|
93
|
+
# Session exists: check if forestui window is already running
|
|
94
|
+
result = subprocess.run(
|
|
95
|
+
["tmux", "select-window", "-t", f"{session_name}:forestui"],
|
|
96
|
+
capture_output=True,
|
|
97
|
+
)
|
|
98
|
+
forestui_window_exists = result.returncode == 0
|
|
99
|
+
|
|
100
|
+
if not forestui_window_exists:
|
|
101
|
+
# forestui was killed but session remains - create new window
|
|
102
|
+
subprocess.run(
|
|
103
|
+
[
|
|
104
|
+
"tmux",
|
|
105
|
+
"new-window",
|
|
106
|
+
"-t",
|
|
107
|
+
session_name,
|
|
108
|
+
"-n",
|
|
109
|
+
"forestui",
|
|
110
|
+
forestui_cmd,
|
|
111
|
+
],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
os.execvp("tmux", ["tmux", "attach-session", "-t", session_name])
|
|
115
|
+
else:
|
|
116
|
+
# No session: create new one with forestui as initial command
|
|
117
|
+
os.execvp("tmux", ["tmux", "new-session", "-s", session_name, forestui_cmd])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@click.command()
|
|
121
|
+
@click.argument("forest_path", required=False, default=None)
|
|
122
|
+
@click.option(
|
|
123
|
+
"--no-self-update",
|
|
124
|
+
"no_self_update",
|
|
125
|
+
is_flag=True,
|
|
126
|
+
help="Disable automatic updates on startup",
|
|
127
|
+
)
|
|
128
|
+
@click.option(
|
|
129
|
+
"--debug",
|
|
130
|
+
"debug_mode",
|
|
131
|
+
is_flag=True,
|
|
132
|
+
help="Run with Textual devtools enabled",
|
|
133
|
+
)
|
|
134
|
+
@click.option(
|
|
135
|
+
"--dev",
|
|
136
|
+
"dev_mode",
|
|
137
|
+
is_flag=True,
|
|
138
|
+
help="Dev mode: use timestamped window name (forestui-dev-HHMM)",
|
|
139
|
+
)
|
|
140
|
+
@click.version_option(version=__version__, prog_name="forestui")
|
|
141
|
+
def main(
|
|
142
|
+
forest_path: str | None, no_self_update: bool, debug_mode: bool, dev_mode: bool
|
|
143
|
+
) -> None:
|
|
144
|
+
"""forestui - Git Worktree Manager
|
|
145
|
+
|
|
146
|
+
A terminal UI for managing Git worktrees, inspired by forest for macOS.
|
|
147
|
+
|
|
148
|
+
FOREST_PATH: Optional path to forest directory (default: ~/forest)
|
|
149
|
+
"""
|
|
150
|
+
ensure_tmux(forest_path, debug_mode, no_self_update, dev_mode)
|
|
151
|
+
|
|
152
|
+
if debug_mode:
|
|
153
|
+
os.environ["TEXTUAL"] = "devtools"
|
|
154
|
+
|
|
155
|
+
if no_self_update:
|
|
156
|
+
os.environ["FORESTUI_NO_AUTO_UPDATE"] = "1"
|
|
157
|
+
|
|
158
|
+
rename_tmux_window(get_window_name(dev_mode))
|
|
159
|
+
|
|
160
|
+
# Import here to avoid circular imports and speed up --help/--version
|
|
161
|
+
from forestui.app import run_app
|
|
162
|
+
from forestui.services.settings import set_forest_path
|
|
163
|
+
|
|
164
|
+
set_forest_path(forest_path)
|
|
165
|
+
run_app()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
main()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""UI Components for forestui."""
|
|
2
|
+
|
|
3
|
+
from forestui.components.modals import (
|
|
4
|
+
AddRepositoryModal,
|
|
5
|
+
AddWorktreeModal,
|
|
6
|
+
ConfirmDeleteModal,
|
|
7
|
+
SettingsModal,
|
|
8
|
+
)
|
|
9
|
+
from forestui.components.repository_detail import RepositoryDetail
|
|
10
|
+
from forestui.components.sidebar import Sidebar
|
|
11
|
+
from forestui.components.worktree_detail import WorktreeDetail
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AddRepositoryModal",
|
|
15
|
+
"AddWorktreeModal",
|
|
16
|
+
"ConfirmDeleteModal",
|
|
17
|
+
"RepositoryDetail",
|
|
18
|
+
"SettingsModal",
|
|
19
|
+
"Sidebar",
|
|
20
|
+
"WorktreeDetail",
|
|
21
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Shared message classes for detail view components."""
|
|
2
|
+
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from textual.message import Message
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpenInEditor(Message):
|
|
9
|
+
"""Request to open a path in editor."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, path: str) -> None:
|
|
12
|
+
self.path = path
|
|
13
|
+
super().__init__()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OpenInTerminal(Message):
|
|
17
|
+
"""Request to open a path in terminal."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, path: str) -> None:
|
|
20
|
+
self.path = path
|
|
21
|
+
super().__init__()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OpenInFileManager(Message):
|
|
25
|
+
"""Request to open a path in file manager."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, path: str) -> None:
|
|
28
|
+
self.path = path
|
|
29
|
+
super().__init__()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StartClaudeSession(Message):
|
|
33
|
+
"""Request to start a new Claude session."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, path: str) -> None:
|
|
36
|
+
self.path = path
|
|
37
|
+
super().__init__()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class StartClaudeYoloSession(Message):
|
|
41
|
+
"""Request to start a Claude YOLO session."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, path: str) -> None:
|
|
44
|
+
self.path = path
|
|
45
|
+
super().__init__()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ContinueClaudeSession(Message):
|
|
49
|
+
"""Request to continue an existing Claude session."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, session_id: str, path: str) -> None:
|
|
52
|
+
self.session_id = session_id
|
|
53
|
+
self.path = path
|
|
54
|
+
super().__init__()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ContinueClaudeYoloSession(Message):
|
|
58
|
+
"""Request to continue an existing Claude session in YOLO mode."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, session_id: str, path: str) -> None:
|
|
61
|
+
self.session_id = session_id
|
|
62
|
+
self.path = path
|
|
63
|
+
super().__init__()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ConfigureClaudeCommand(Message):
|
|
67
|
+
"""Request to configure custom Claude command for a repository or worktree."""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
repo_id: UUID,
|
|
72
|
+
worktree_id: UUID | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
self.repo_id = repo_id
|
|
75
|
+
self.worktree_id = worktree_id
|
|
76
|
+
super().__init__()
|