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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Settings service for managing application configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from forestui.models import Settings
|
|
9
|
+
|
|
10
|
+
# Default forest directory
|
|
11
|
+
DEFAULT_FOREST_PATH = Path.home() / "forest"
|
|
12
|
+
|
|
13
|
+
# Runtime forest path (set at startup via CLI argument)
|
|
14
|
+
_forest_path: Path | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def set_forest_path(path: Path | str | None) -> None:
|
|
18
|
+
"""Set the runtime forest path."""
|
|
19
|
+
global _forest_path
|
|
20
|
+
if path is None:
|
|
21
|
+
_forest_path = DEFAULT_FOREST_PATH
|
|
22
|
+
else:
|
|
23
|
+
_forest_path = Path(path).expanduser().resolve()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_forest_path() -> Path:
|
|
27
|
+
"""Get the current forest path."""
|
|
28
|
+
if _forest_path is None:
|
|
29
|
+
return DEFAULT_FOREST_PATH
|
|
30
|
+
return _forest_path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SettingsService:
|
|
34
|
+
"""Service for managing application settings."""
|
|
35
|
+
|
|
36
|
+
_instance: SettingsService | None = None
|
|
37
|
+
_settings: Settings | None = None
|
|
38
|
+
_config_path: Path = Path.home() / ".config" / "forestui" / "settings.json"
|
|
39
|
+
|
|
40
|
+
def __new__(cls) -> SettingsService:
|
|
41
|
+
if cls._instance is None:
|
|
42
|
+
cls._instance = super().__new__(cls)
|
|
43
|
+
return cls._instance
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
if self._settings is None:
|
|
47
|
+
self._settings = self._load_settings()
|
|
48
|
+
|
|
49
|
+
def _load_settings(self) -> Settings:
|
|
50
|
+
"""Load settings from config file."""
|
|
51
|
+
if self._config_path.exists():
|
|
52
|
+
try:
|
|
53
|
+
with self._config_path.open(encoding="utf-8") as f:
|
|
54
|
+
data = json.load(f)
|
|
55
|
+
return Settings.model_validate(data)
|
|
56
|
+
except (json.JSONDecodeError, OSError):
|
|
57
|
+
pass
|
|
58
|
+
return Settings.default()
|
|
59
|
+
|
|
60
|
+
def save_settings(self, settings: Settings) -> None:
|
|
61
|
+
"""Save settings to config file."""
|
|
62
|
+
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
with self._config_path.open("w", encoding="utf-8") as f:
|
|
64
|
+
json.dump(settings.model_dump(), f, indent=2)
|
|
65
|
+
self._settings = settings
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def settings(self) -> Settings:
|
|
69
|
+
"""Get current settings."""
|
|
70
|
+
if self._settings is None:
|
|
71
|
+
self._settings = self._load_settings()
|
|
72
|
+
return self._settings
|
|
73
|
+
|
|
74
|
+
def update(self, **kwargs: str) -> None:
|
|
75
|
+
"""Update settings with new values."""
|
|
76
|
+
current = self.settings.model_dump()
|
|
77
|
+
current.update(kwargs)
|
|
78
|
+
new_settings = Settings.model_validate(current)
|
|
79
|
+
self.save_settings(new_settings)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_settings_service() -> SettingsService:
|
|
83
|
+
"""Get the singleton SettingsService instance."""
|
|
84
|
+
return SettingsService()
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Service for tmux integration using libtmux."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
|
|
8
|
+
from libtmux import Server
|
|
9
|
+
from libtmux.exc import LibTmuxException
|
|
10
|
+
from libtmux.session import Session
|
|
11
|
+
from libtmux.window import Window
|
|
12
|
+
|
|
13
|
+
TUI_EDITORS = {
|
|
14
|
+
"vim",
|
|
15
|
+
"nvim",
|
|
16
|
+
"vi",
|
|
17
|
+
"emacs",
|
|
18
|
+
"nano",
|
|
19
|
+
"helix",
|
|
20
|
+
"hx",
|
|
21
|
+
"micro",
|
|
22
|
+
"kakoune",
|
|
23
|
+
"kak",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TmuxService:
|
|
28
|
+
"""Service for interacting with tmux sessions via libtmux."""
|
|
29
|
+
|
|
30
|
+
_instance: TmuxService | None = None
|
|
31
|
+
_server: Server | None = None
|
|
32
|
+
_session: Session | None = None
|
|
33
|
+
|
|
34
|
+
def __new__(cls) -> TmuxService:
|
|
35
|
+
if cls._instance is None:
|
|
36
|
+
cls._instance = super().__new__(cls)
|
|
37
|
+
return cls._instance
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def is_inside_tmux(self) -> bool:
|
|
41
|
+
"""Check if we are running inside a tmux session."""
|
|
42
|
+
return bool(os.environ.get("TMUX"))
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def server(self) -> Server | None:
|
|
46
|
+
"""Get the tmux server."""
|
|
47
|
+
if not self.is_inside_tmux:
|
|
48
|
+
return None
|
|
49
|
+
if self._server is None:
|
|
50
|
+
try:
|
|
51
|
+
self._server = Server()
|
|
52
|
+
except LibTmuxException:
|
|
53
|
+
return None
|
|
54
|
+
return self._server
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def session(self) -> Session | None:
|
|
58
|
+
"""Get the current tmux session."""
|
|
59
|
+
if not self.is_inside_tmux or self.server is None:
|
|
60
|
+
return None
|
|
61
|
+
if self._session is None:
|
|
62
|
+
try:
|
|
63
|
+
# Get session from TMUX environment variable
|
|
64
|
+
# Format: /socket/path,pid,window_index
|
|
65
|
+
tmux_env = os.environ.get("TMUX", "")
|
|
66
|
+
if tmux_env:
|
|
67
|
+
# Find the attached session (session_attached is a count > 0)
|
|
68
|
+
for sess in self.server.sessions:
|
|
69
|
+
attached = sess.session_attached
|
|
70
|
+
if attached and int(attached) > 0:
|
|
71
|
+
self._session = sess
|
|
72
|
+
break
|
|
73
|
+
# Fallback to first session if none found
|
|
74
|
+
if self._session is None and self.server.sessions:
|
|
75
|
+
self._session = self.server.sessions[0]
|
|
76
|
+
except (LibTmuxException, ValueError, TypeError):
|
|
77
|
+
return None
|
|
78
|
+
return self._session
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def current_window(self) -> Window | None:
|
|
82
|
+
"""Get the current tmux window."""
|
|
83
|
+
if self.session is None:
|
|
84
|
+
return None
|
|
85
|
+
try:
|
|
86
|
+
return self.session.active_window
|
|
87
|
+
except LibTmuxException:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def rename_window(self, name: str) -> bool:
|
|
91
|
+
"""Rename the current tmux window."""
|
|
92
|
+
window = self.current_window
|
|
93
|
+
if window is None:
|
|
94
|
+
return False
|
|
95
|
+
try:
|
|
96
|
+
window.rename_window(name)
|
|
97
|
+
return True
|
|
98
|
+
except LibTmuxException:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
def ensure_focus_events(self) -> bool:
|
|
102
|
+
"""Ensure tmux focus-events option is enabled for proper app refresh.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
True if focus events were enabled successfully, False otherwise.
|
|
106
|
+
"""
|
|
107
|
+
if self.server is None:
|
|
108
|
+
return False
|
|
109
|
+
try:
|
|
110
|
+
self.server.cmd("set-option", "-g", "focus-events", "on")
|
|
111
|
+
return True
|
|
112
|
+
except LibTmuxException:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
def is_tui_editor(self, editor: str) -> bool:
|
|
116
|
+
"""Check if an editor is a TUI editor that should run in tmux."""
|
|
117
|
+
# Get base command (handle "emacs -nw" -> "emacs")
|
|
118
|
+
base_cmd = editor.split()[0]
|
|
119
|
+
return base_cmd in TUI_EDITORS
|
|
120
|
+
|
|
121
|
+
def find_window(self, name: str) -> Window | None:
|
|
122
|
+
"""Find a window by name in the current session."""
|
|
123
|
+
if self.session is None:
|
|
124
|
+
return None
|
|
125
|
+
try:
|
|
126
|
+
for window in self.session.windows:
|
|
127
|
+
if window.window_name == name:
|
|
128
|
+
return window
|
|
129
|
+
except LibTmuxException:
|
|
130
|
+
pass
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
def create_editor_window(
|
|
134
|
+
self,
|
|
135
|
+
worktree_name: str,
|
|
136
|
+
worktree_path: str,
|
|
137
|
+
editor: str,
|
|
138
|
+
) -> bool:
|
|
139
|
+
"""Create a tmux window with the editor open in the worktree.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
worktree_name: Name of the worktree (used for window naming)
|
|
143
|
+
worktree_path: Path to the worktree directory
|
|
144
|
+
editor: Editor command to run
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
True if window was created/selected successfully, False otherwise
|
|
148
|
+
"""
|
|
149
|
+
if self.session is None:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
window_name = f"edit:{worktree_name}"
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
# Check if window already exists
|
|
156
|
+
existing_window = self.find_window(window_name)
|
|
157
|
+
if existing_window is not None:
|
|
158
|
+
existing_window.select()
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
# Create new window with editor as the command (closes when editor exits)
|
|
162
|
+
self.session.new_window(
|
|
163
|
+
window_name=window_name,
|
|
164
|
+
start_directory=worktree_path,
|
|
165
|
+
attach=True,
|
|
166
|
+
window_shell=f"{editor} .",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
except LibTmuxException:
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
def create_shell_window(self, name: str, path: str) -> bool:
|
|
175
|
+
"""Create a tmux window with a shell.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
name: Name for the window
|
|
179
|
+
path: Working directory path
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
True if window was created successfully, False otherwise
|
|
183
|
+
"""
|
|
184
|
+
if self.session is None:
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
base_window_name = f"term:{name}"
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
# Always create a new window with unique name
|
|
191
|
+
window_name = self._find_unique_window_name(base_window_name)
|
|
192
|
+
|
|
193
|
+
self.session.new_window(
|
|
194
|
+
window_name=window_name,
|
|
195
|
+
start_directory=path,
|
|
196
|
+
attach=True,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
except LibTmuxException:
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
def create_mc_window(self, name: str, path: str) -> bool:
|
|
205
|
+
"""Create a tmux window with Midnight Commander.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
name: Name for the window
|
|
209
|
+
path: Working directory path
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
True if window was created successfully, False otherwise
|
|
213
|
+
"""
|
|
214
|
+
if self.session is None:
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
base_window_name = f"files:{name}"
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
# Always create a new window with unique name
|
|
221
|
+
window_name = self._find_unique_window_name(base_window_name)
|
|
222
|
+
|
|
223
|
+
self.session.new_window(
|
|
224
|
+
window_name=window_name,
|
|
225
|
+
start_directory=path,
|
|
226
|
+
attach=True,
|
|
227
|
+
window_shell="mc",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
except LibTmuxException:
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
def _find_unique_window_name(self, base_name: str) -> str:
|
|
236
|
+
"""Find a unique window name by adding :2, :3, etc. suffix if needed.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
base_name: The base window name (e.g., "yolo:cogram")
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
A unique window name (e.g., "yolo:cogram" or "yolo:cogram:2")
|
|
243
|
+
"""
|
|
244
|
+
if self.session is None:
|
|
245
|
+
return base_name
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
existing_names = {w.window_name for w in self.session.windows}
|
|
249
|
+
except LibTmuxException:
|
|
250
|
+
return base_name
|
|
251
|
+
|
|
252
|
+
if base_name not in existing_names:
|
|
253
|
+
return base_name
|
|
254
|
+
|
|
255
|
+
# Find next available suffix
|
|
256
|
+
counter = 2
|
|
257
|
+
while f"{base_name}:{counter}" in existing_names:
|
|
258
|
+
counter += 1
|
|
259
|
+
return f"{base_name}:{counter}"
|
|
260
|
+
|
|
261
|
+
def create_claude_window(
|
|
262
|
+
self,
|
|
263
|
+
name: str,
|
|
264
|
+
path: str,
|
|
265
|
+
resume_session_id: str | None = None,
|
|
266
|
+
yolo: bool = False,
|
|
267
|
+
custom_command: str | None = None,
|
|
268
|
+
) -> str | None:
|
|
269
|
+
"""Create a tmux window with Claude Code.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
name: Name for the window (e.g., worktree name)
|
|
273
|
+
path: Working directory path
|
|
274
|
+
resume_session_id: Optional session ID to resume
|
|
275
|
+
yolo: If True, add --dangerously-skip-permissions flag
|
|
276
|
+
custom_command: Optional custom Claude command (e.g., "claude --model opus")
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
The window name if created/selected successfully, None otherwise
|
|
280
|
+
"""
|
|
281
|
+
if self.session is None:
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
base_window_name = f"claude:{name}"
|
|
285
|
+
if yolo:
|
|
286
|
+
base_window_name = f"yolo:{name}"
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
# Always create a new window with unique name (add :2, :3 suffix if needed)
|
|
290
|
+
window_name = self._find_unique_window_name(base_window_name)
|
|
291
|
+
|
|
292
|
+
# Build claude command (closes when claude exits)
|
|
293
|
+
# Use custom_command if provided, otherwise default to "claude"
|
|
294
|
+
cmd = custom_command or "claude"
|
|
295
|
+
if yolo:
|
|
296
|
+
cmd += " --dangerously-skip-permissions"
|
|
297
|
+
if resume_session_id:
|
|
298
|
+
cmd += f" -r {resume_session_id}"
|
|
299
|
+
|
|
300
|
+
# Wrap in interactive shell to support aliases
|
|
301
|
+
# Use shlex.quote to prevent shell injection from custom commands
|
|
302
|
+
shell = os.environ.get("SHELL", "/bin/bash")
|
|
303
|
+
shell_cmd = f"{shell} -ic {shlex.quote(cmd)}"
|
|
304
|
+
|
|
305
|
+
self.session.new_window(
|
|
306
|
+
window_name=window_name,
|
|
307
|
+
start_directory=path,
|
|
308
|
+
attach=True,
|
|
309
|
+
window_shell=shell_cmd,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return window_name
|
|
313
|
+
|
|
314
|
+
except LibTmuxException:
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def get_tmux_service() -> TmuxService:
|
|
319
|
+
"""Get the singleton TmuxService instance."""
|
|
320
|
+
return TmuxService()
|
forestui/state.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Application state management for forestui."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from forestui.models import Repository, Selection, Worktree
|
|
11
|
+
from forestui.services.settings import get_forest_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AppStateData(BaseModel):
|
|
15
|
+
"""Serializable app state data."""
|
|
16
|
+
|
|
17
|
+
repositories: list[Repository] = []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AppState:
|
|
21
|
+
"""Centralized application state with persistence."""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self._repositories: list[Repository] = []
|
|
25
|
+
self._selection: Selection = Selection()
|
|
26
|
+
self._show_archived: bool = False
|
|
27
|
+
self._load_state()
|
|
28
|
+
|
|
29
|
+
def _get_config_path(self) -> Path:
|
|
30
|
+
"""Get the config file path."""
|
|
31
|
+
forest_dir = get_forest_path()
|
|
32
|
+
forest_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
return forest_dir / ".forestui-config.json"
|
|
34
|
+
|
|
35
|
+
def _load_state(self) -> None:
|
|
36
|
+
"""Load state from config file."""
|
|
37
|
+
config_path = self._get_config_path()
|
|
38
|
+
if config_path.exists():
|
|
39
|
+
try:
|
|
40
|
+
with config_path.open(encoding="utf-8") as f:
|
|
41
|
+
data = json.load(f)
|
|
42
|
+
state_data = AppStateData.model_validate(data)
|
|
43
|
+
self._repositories = state_data.repositories
|
|
44
|
+
except (json.JSONDecodeError, OSError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
def _save_state(self) -> None:
|
|
48
|
+
"""Save state to config file."""
|
|
49
|
+
config_path = self._get_config_path()
|
|
50
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
state_data = AppStateData(repositories=self._repositories)
|
|
52
|
+
with config_path.open("w", encoding="utf-8") as f:
|
|
53
|
+
json.dump(state_data.model_dump(mode="json"), f, indent=2, default=str)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def repositories(self) -> list[Repository]:
|
|
57
|
+
"""Get all repositories."""
|
|
58
|
+
return self._repositories
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def selection(self) -> Selection:
|
|
62
|
+
"""Get current selection."""
|
|
63
|
+
return self._selection
|
|
64
|
+
|
|
65
|
+
@selection.setter
|
|
66
|
+
def selection(self, value: Selection) -> None:
|
|
67
|
+
"""Set current selection."""
|
|
68
|
+
self._selection = value
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def show_archived(self) -> bool:
|
|
72
|
+
"""Get show archived flag."""
|
|
73
|
+
return self._show_archived
|
|
74
|
+
|
|
75
|
+
@show_archived.setter
|
|
76
|
+
def show_archived(self, value: bool) -> None:
|
|
77
|
+
"""Set show archived flag."""
|
|
78
|
+
self._show_archived = value
|
|
79
|
+
|
|
80
|
+
def add_repository(self, repository: Repository) -> None:
|
|
81
|
+
"""Add a repository."""
|
|
82
|
+
self._repositories.append(repository)
|
|
83
|
+
self._save_state()
|
|
84
|
+
|
|
85
|
+
def remove_repository(self, repo_id: UUID) -> None:
|
|
86
|
+
"""Remove a repository by ID."""
|
|
87
|
+
self._repositories = [r for r in self._repositories if r.id != repo_id]
|
|
88
|
+
if self._selection.repository_id == repo_id:
|
|
89
|
+
self._selection = Selection()
|
|
90
|
+
self._save_state()
|
|
91
|
+
|
|
92
|
+
def find_repository(self, repo_id: UUID) -> Repository | None:
|
|
93
|
+
"""Find a repository by ID."""
|
|
94
|
+
for repo in self._repositories:
|
|
95
|
+
if repo.id == repo_id:
|
|
96
|
+
return repo
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
def update_repository_command(self, repo_id: UUID, command: str | None) -> bool:
|
|
100
|
+
"""Update a repository's custom Claude command.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
True if the repository was found and updated, False otherwise.
|
|
104
|
+
"""
|
|
105
|
+
for repo in self._repositories:
|
|
106
|
+
if repo.id == repo_id:
|
|
107
|
+
repo.custom_claude_command = command
|
|
108
|
+
self._save_state()
|
|
109
|
+
return True
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
def update_worktree_command(self, worktree_id: UUID, command: str | None) -> bool:
|
|
113
|
+
"""Update a worktree's custom Claude command.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True if the worktree was found and updated, False otherwise.
|
|
117
|
+
"""
|
|
118
|
+
for repo in self._repositories:
|
|
119
|
+
for worktree in repo.worktrees:
|
|
120
|
+
if worktree.id == worktree_id:
|
|
121
|
+
worktree.custom_claude_command = command
|
|
122
|
+
self._save_state()
|
|
123
|
+
return True
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
def find_worktree(self, worktree_id: UUID) -> tuple[Repository, Worktree] | None:
|
|
127
|
+
"""Find a worktree by ID and return with its parent repository."""
|
|
128
|
+
for repo in self._repositories:
|
|
129
|
+
for worktree in repo.worktrees:
|
|
130
|
+
if worktree.id == worktree_id:
|
|
131
|
+
return repo, worktree
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
def add_worktree(self, repo_id: UUID, worktree: Worktree) -> None:
|
|
135
|
+
"""Add a worktree to a repository."""
|
|
136
|
+
for repo in self._repositories:
|
|
137
|
+
if repo.id == repo_id:
|
|
138
|
+
repo.worktrees.append(worktree)
|
|
139
|
+
self._save_state()
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
def remove_worktree(self, worktree_id: UUID) -> None:
|
|
143
|
+
"""Remove a worktree by ID."""
|
|
144
|
+
for repo in self._repositories:
|
|
145
|
+
repo.worktrees = [w for w in repo.worktrees if w.id != worktree_id]
|
|
146
|
+
if self._selection.worktree_id == worktree_id:
|
|
147
|
+
self._selection = Selection(repository_id=self._selection.repository_id)
|
|
148
|
+
self._save_state()
|
|
149
|
+
|
|
150
|
+
def update_worktree(
|
|
151
|
+
self, worktree_id: UUID, **kwargs: str | bool | int | None
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Update a worktree's attributes."""
|
|
154
|
+
for repo in self._repositories:
|
|
155
|
+
for i, worktree in enumerate(repo.worktrees):
|
|
156
|
+
if worktree.id == worktree_id:
|
|
157
|
+
data = worktree.model_dump()
|
|
158
|
+
data.update(kwargs)
|
|
159
|
+
repo.worktrees[i] = Worktree.model_validate(data)
|
|
160
|
+
self._save_state()
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
def archive_worktree(self, worktree_id: UUID) -> None:
|
|
164
|
+
"""Archive a worktree."""
|
|
165
|
+
self.update_worktree(worktree_id, is_archived=True)
|
|
166
|
+
|
|
167
|
+
def unarchive_worktree(self, worktree_id: UUID) -> None:
|
|
168
|
+
"""Unarchive a worktree."""
|
|
169
|
+
self.update_worktree(worktree_id, is_archived=False)
|
|
170
|
+
|
|
171
|
+
def select_repository(self, repo_id: UUID) -> None:
|
|
172
|
+
"""Select a repository."""
|
|
173
|
+
self._selection = Selection(repository_id=repo_id)
|
|
174
|
+
|
|
175
|
+
def select_worktree(self, repo_id: UUID, worktree_id: UUID) -> None:
|
|
176
|
+
"""Select a worktree."""
|
|
177
|
+
self._selection = Selection(repository_id=repo_id, worktree_id=worktree_id)
|
|
178
|
+
|
|
179
|
+
def clear_selection(self) -> None:
|
|
180
|
+
"""Clear the current selection."""
|
|
181
|
+
self._selection = Selection()
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def selected_repository(self) -> Repository | None:
|
|
185
|
+
"""Get the currently selected repository."""
|
|
186
|
+
if self._selection.repository_id:
|
|
187
|
+
return self.find_repository(self._selection.repository_id)
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def selected_worktree(self) -> tuple[Repository, Worktree] | None:
|
|
192
|
+
"""Get the currently selected worktree with its parent repo."""
|
|
193
|
+
if self._selection.worktree_id:
|
|
194
|
+
return self.find_worktree(self._selection.worktree_id)
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
def has_archived_worktrees(self) -> bool:
|
|
198
|
+
"""Check if there are any archived worktrees."""
|
|
199
|
+
for repo in self._repositories:
|
|
200
|
+
if any(w.is_archived for w in repo.worktrees):
|
|
201
|
+
return True
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
def all_archived_worktrees(self) -> list[tuple[Repository, Worktree]]:
|
|
205
|
+
"""Get all archived worktrees with their parent repositories."""
|
|
206
|
+
result: list[tuple[Repository, Worktree]] = []
|
|
207
|
+
for repo in self._repositories:
|
|
208
|
+
for worktree in repo.archived_worktrees():
|
|
209
|
+
result.append((repo, worktree))
|
|
210
|
+
return result
|
|
211
|
+
|
|
212
|
+
def reorder_worktree(
|
|
213
|
+
self, repo_id: UUID, worktree_id: UUID, new_index: int
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Reorder a worktree within its repository."""
|
|
216
|
+
repo = self.find_repository(repo_id)
|
|
217
|
+
if not repo:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
# Get active worktrees in current order
|
|
221
|
+
active = repo.active_worktrees()
|
|
222
|
+
worktree = next((w for w in active if w.id == worktree_id), None)
|
|
223
|
+
if not worktree:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
# Remove and reinsert at new position
|
|
227
|
+
active = [w for w in active if w.id != worktree_id]
|
|
228
|
+
active.insert(min(new_index, len(active)), worktree)
|
|
229
|
+
|
|
230
|
+
# Update sort orders
|
|
231
|
+
for i, w in enumerate(active):
|
|
232
|
+
self.update_worktree(w.id, sort_order=i)
|
|
233
|
+
|
|
234
|
+
def refresh_worktree_timestamp(self, worktree_id: UUID) -> None:
|
|
235
|
+
"""Update a worktree's last modified timestamp."""
|
|
236
|
+
self.update_worktree(worktree_id, last_modified=datetime.now(UTC).isoformat())
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# Global app state instance
|
|
240
|
+
_app_state: AppState | None = None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def get_app_state() -> AppState:
|
|
244
|
+
"""Get the global app state instance."""
|
|
245
|
+
global _app_state
|
|
246
|
+
if _app_state is None:
|
|
247
|
+
_app_state = AppState()
|
|
248
|
+
return _app_state
|