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/app.py
ADDED
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
"""Main forestui application."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from textual import work
|
|
9
|
+
from textual.app import App, ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
12
|
+
from textual.widget import Widget
|
|
13
|
+
from textual.widgets import Footer, Header, Label
|
|
14
|
+
|
|
15
|
+
from forestui import __version__
|
|
16
|
+
from forestui.components.messages import (
|
|
17
|
+
ConfigureClaudeCommand,
|
|
18
|
+
ContinueClaudeSession,
|
|
19
|
+
ContinueClaudeYoloSession,
|
|
20
|
+
OpenInEditor,
|
|
21
|
+
OpenInFileManager,
|
|
22
|
+
OpenInTerminal,
|
|
23
|
+
StartClaudeSession,
|
|
24
|
+
StartClaudeYoloSession,
|
|
25
|
+
)
|
|
26
|
+
from forestui.components.modals import (
|
|
27
|
+
AddRepositoryModal,
|
|
28
|
+
AddWorktreeModal,
|
|
29
|
+
ClaudeCommandModal,
|
|
30
|
+
ConfirmDeleteModal,
|
|
31
|
+
CreateWorktreeFromIssueModal,
|
|
32
|
+
SettingsModal,
|
|
33
|
+
)
|
|
34
|
+
from forestui.components.repository_detail import RepositoryDetail
|
|
35
|
+
from forestui.components.sidebar import Sidebar
|
|
36
|
+
from forestui.components.worktree_detail import WorktreeDetail
|
|
37
|
+
from forestui.models import ClaudeCommandResult, GitHubIssue, Repository, Worktree
|
|
38
|
+
from forestui.services.claude_session import get_claude_session_service
|
|
39
|
+
from forestui.services.git import GitError, get_git_service
|
|
40
|
+
from forestui.services.github import get_github_service
|
|
41
|
+
from forestui.services.settings import get_forest_path, get_settings_service
|
|
42
|
+
from forestui.services.tmux import get_tmux_service
|
|
43
|
+
from forestui.state import get_app_state
|
|
44
|
+
from forestui.theme import APP_CSS
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class EmptyState(Widget):
|
|
48
|
+
"""Empty state when nothing is selected."""
|
|
49
|
+
|
|
50
|
+
def compose(self) -> ComposeResult:
|
|
51
|
+
"""Compose the empty state UI."""
|
|
52
|
+
with Vertical(classes="empty-state"):
|
|
53
|
+
yield Label(" forestui", classes="label-accent")
|
|
54
|
+
yield Label("Git Worktree Manager", classes="label-secondary")
|
|
55
|
+
yield Label("")
|
|
56
|
+
yield Label("Select a repository or worktree", classes="label-muted")
|
|
57
|
+
yield Label("or press [a] to add a repository", classes="label-muted")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ForestApp(App[None]):
|
|
61
|
+
"""Main forestui application."""
|
|
62
|
+
|
|
63
|
+
TITLE = f"forestui v{__version__}"
|
|
64
|
+
CSS = APP_CSS
|
|
65
|
+
|
|
66
|
+
BINDINGS = [
|
|
67
|
+
Binding("q", "quit", "Quit", show=True, priority=True),
|
|
68
|
+
Binding("a", "add_repository", "Add Repo", show=True),
|
|
69
|
+
Binding("w", "add_worktree", "Add Worktree", show=True),
|
|
70
|
+
Binding("e", "open_editor", "Editor", show=True),
|
|
71
|
+
Binding("t", "open_terminal", "Terminal", show=True),
|
|
72
|
+
Binding("o", "open_files", "Files", show=True),
|
|
73
|
+
Binding("n", "start_claude", "Claude", show=True),
|
|
74
|
+
Binding("y", "start_claude_yolo", "ClaudeYOLO", show=True),
|
|
75
|
+
Binding("h", "toggle_archive", "Archive", show=True),
|
|
76
|
+
Binding("d", "delete", "Delete", show=True),
|
|
77
|
+
Binding("s", "open_settings", "Settings", show=True),
|
|
78
|
+
Binding("r", "refresh", "Refresh", show=False),
|
|
79
|
+
Binding("?", "show_help", "Help", show=True),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
def __init__(self) -> None:
|
|
83
|
+
super().__init__()
|
|
84
|
+
self._state = get_app_state()
|
|
85
|
+
self._settings_service = get_settings_service()
|
|
86
|
+
self._git_service = get_git_service()
|
|
87
|
+
self._claude_service = get_claude_session_service()
|
|
88
|
+
self._tmux_service = get_tmux_service()
|
|
89
|
+
self._github_service = get_github_service()
|
|
90
|
+
|
|
91
|
+
def compose(self) -> ComposeResult:
|
|
92
|
+
"""Compose the application UI."""
|
|
93
|
+
yield Header()
|
|
94
|
+
with Horizontal(id="main-container"):
|
|
95
|
+
yield Sidebar(
|
|
96
|
+
repositories=self._state.repositories,
|
|
97
|
+
selected_repo_id=self._state.selection.repository_id,
|
|
98
|
+
selected_worktree_id=self._state.selection.worktree_id,
|
|
99
|
+
show_archived=self._state.show_archived,
|
|
100
|
+
)
|
|
101
|
+
with VerticalScroll(id="detail-pane"):
|
|
102
|
+
yield EmptyState()
|
|
103
|
+
yield Footer()
|
|
104
|
+
|
|
105
|
+
async def on_mount(self) -> None:
|
|
106
|
+
"""Handle app mount - auto-select first repo if nothing selected."""
|
|
107
|
+
# Ensure tmux focus events are enabled for auto-refresh
|
|
108
|
+
if not self._tmux_service.ensure_focus_events():
|
|
109
|
+
self.notify("Could not enable focus events", severity="warning")
|
|
110
|
+
|
|
111
|
+
if not self._state.selection.repository_id and self._state.repositories:
|
|
112
|
+
self._state.select_repository(self._state.repositories[0].id)
|
|
113
|
+
await self._refresh_detail_pane()
|
|
114
|
+
# Auto-update in background
|
|
115
|
+
self._auto_update()
|
|
116
|
+
|
|
117
|
+
# Check GitHub CLI status
|
|
118
|
+
self._check_gh_status()
|
|
119
|
+
|
|
120
|
+
# Start GitHub issues refresh timer (5 minutes)
|
|
121
|
+
self.set_interval(300, self._refresh_github_issues)
|
|
122
|
+
|
|
123
|
+
@work
|
|
124
|
+
async def on_app_focus(self) -> None:
|
|
125
|
+
"""Refresh detail pane when app regains focus."""
|
|
126
|
+
await self._refresh_detail_pane()
|
|
127
|
+
|
|
128
|
+
@work
|
|
129
|
+
async def _check_gh_status(self) -> None:
|
|
130
|
+
"""Check and display GitHub CLI auth status."""
|
|
131
|
+
status, username = await self._github_service.get_auth_status()
|
|
132
|
+
sidebar = self.query_one(Sidebar)
|
|
133
|
+
sidebar.set_gh_status(status, username)
|
|
134
|
+
|
|
135
|
+
@work
|
|
136
|
+
async def _refresh_github_issues(self) -> None:
|
|
137
|
+
"""Periodically refresh GitHub issues cache."""
|
|
138
|
+
self._github_service.invalidate_cache()
|
|
139
|
+
if self._state.selection.repository_id:
|
|
140
|
+
repo = self._state.find_repository(self._state.selection.repository_id)
|
|
141
|
+
if repo:
|
|
142
|
+
self._fetch_issues_for_repo(repo.source_path)
|
|
143
|
+
|
|
144
|
+
@work
|
|
145
|
+
async def _fetch_issues_for_repo(self, repo_path: str) -> None:
|
|
146
|
+
"""Fetch GitHub issues in background and update the detail pane."""
|
|
147
|
+
issues: list[GitHubIssue] = []
|
|
148
|
+
try:
|
|
149
|
+
issues = await self._github_service.list_issues(repo_path)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
self.notify(f"Issue fetch error: {e}", severity="error")
|
|
152
|
+
# Update the detail pane if it's still showing a RepositoryDetail
|
|
153
|
+
try:
|
|
154
|
+
detail = self.query_one(RepositoryDetail)
|
|
155
|
+
detail.update_issues(issues)
|
|
156
|
+
except Exception:
|
|
157
|
+
pass # Detail pane changed, ignore
|
|
158
|
+
|
|
159
|
+
@work
|
|
160
|
+
async def _fetch_sessions_for_path(self, path: str, detail_type: str) -> None:
|
|
161
|
+
"""Fetch Claude sessions in background and update the detail pane."""
|
|
162
|
+
sessions = self._claude_service.get_sessions_for_path(path)
|
|
163
|
+
# Update the appropriate detail pane
|
|
164
|
+
try:
|
|
165
|
+
if detail_type == "repository":
|
|
166
|
+
self.query_one(RepositoryDetail).update_sessions(sessions)
|
|
167
|
+
else:
|
|
168
|
+
self.query_one(WorktreeDetail).update_sessions(sessions)
|
|
169
|
+
except Exception:
|
|
170
|
+
pass # Detail pane changed, ignore
|
|
171
|
+
|
|
172
|
+
def _set_title_suffix(self, suffix: str | None) -> None:
|
|
173
|
+
"""Update title with optional suffix."""
|
|
174
|
+
base = f"forestui v{__version__}"
|
|
175
|
+
self.title = f"{base} ({suffix})" if suffix else base
|
|
176
|
+
|
|
177
|
+
@work(thread=True)
|
|
178
|
+
def _auto_update(self) -> None:
|
|
179
|
+
"""Auto-update via PyPI with status in title bar.
|
|
180
|
+
|
|
181
|
+
Uses `uv tool install forestui --force --upgrade` to check PyPI for updates.
|
|
182
|
+
|
|
183
|
+
IMPORTANT: We use `install --force --upgrade` instead of `upgrade` because
|
|
184
|
+
`uv tool upgrade` rebuilds from the original install source. For users who
|
|
185
|
+
installed via the old git-clone method, that would rebuild from the local
|
|
186
|
+
~/.forestui-install directory instead of fetching from PyPI.
|
|
187
|
+
|
|
188
|
+
The `--upgrade` flag ensures we only reinstall if a newer version exists.
|
|
189
|
+
The `--force` flag ensures we overwrite the existing installation.
|
|
190
|
+
"""
|
|
191
|
+
import os
|
|
192
|
+
import re
|
|
193
|
+
|
|
194
|
+
if os.environ.get("FORESTUI_NO_AUTO_UPDATE"):
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
self._set_title_suffix("checking for updates...")
|
|
199
|
+
|
|
200
|
+
# Install from PyPI if newer version available
|
|
201
|
+
# MUST use "install --force --upgrade", NOT "upgrade"
|
|
202
|
+
# See docstring for explanation
|
|
203
|
+
result = subprocess.run(
|
|
204
|
+
["uv", "tool", "install", "forestui", "--force", "--upgrade"],
|
|
205
|
+
capture_output=True,
|
|
206
|
+
text=True,
|
|
207
|
+
timeout=120,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if result.returncode == 0:
|
|
211
|
+
# Check if we actually upgraded (vs already up to date)
|
|
212
|
+
# Output contains "Installed forestui v0.9.1" when installed/upgraded
|
|
213
|
+
# Output contains "forestui is already installed" when up to date
|
|
214
|
+
if "Installed" in result.stdout or "Upgraded" in result.stdout:
|
|
215
|
+
# Try to extract version from output
|
|
216
|
+
match = re.search(r"v(\d+\.\d+\.\d+)", result.stdout)
|
|
217
|
+
if match:
|
|
218
|
+
new_version = match.group(1)
|
|
219
|
+
self._set_title_suffix(
|
|
220
|
+
f"updated to v{new_version} - restart to apply"
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
self._set_title_suffix("updated - restart to apply")
|
|
224
|
+
else:
|
|
225
|
+
# Already up to date
|
|
226
|
+
self._set_title_suffix(None)
|
|
227
|
+
else:
|
|
228
|
+
# Command failed - might not be on PyPI yet or network issue
|
|
229
|
+
self._set_title_suffix(None)
|
|
230
|
+
|
|
231
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
|
|
232
|
+
# Silent failure - don't disrupt the app
|
|
233
|
+
self._set_title_suffix(None)
|
|
234
|
+
|
|
235
|
+
def _refresh_sidebar(self) -> None:
|
|
236
|
+
"""Refresh the sidebar with current state."""
|
|
237
|
+
sidebar = self.query_one(Sidebar)
|
|
238
|
+
sidebar.update_repositories(
|
|
239
|
+
repositories=self._state.repositories,
|
|
240
|
+
selected_repo_id=self._state.selection.repository_id,
|
|
241
|
+
selected_worktree_id=self._state.selection.worktree_id,
|
|
242
|
+
show_archived=self._state.show_archived,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
async def _refresh_detail_pane(self) -> None:
|
|
246
|
+
"""Refresh the detail pane based on selection."""
|
|
247
|
+
detail_pane = self.query_one("#detail-pane")
|
|
248
|
+
|
|
249
|
+
# Clear existing content
|
|
250
|
+
await detail_pane.remove_children()
|
|
251
|
+
|
|
252
|
+
selection = self._state.selection
|
|
253
|
+
|
|
254
|
+
if selection.worktree_id:
|
|
255
|
+
# Show worktree detail
|
|
256
|
+
result = self._state.find_worktree(selection.worktree_id)
|
|
257
|
+
if result:
|
|
258
|
+
repo, worktree = result
|
|
259
|
+
# Get commit info
|
|
260
|
+
commit_hash = ""
|
|
261
|
+
commit_time = None
|
|
262
|
+
has_remote = False
|
|
263
|
+
try:
|
|
264
|
+
commit_info = await self._git_service.get_latest_commit(
|
|
265
|
+
worktree.path
|
|
266
|
+
)
|
|
267
|
+
commit_hash = commit_info.short_hash
|
|
268
|
+
commit_time = commit_info.timestamp
|
|
269
|
+
has_remote = await self._git_service.has_remote_tracking(
|
|
270
|
+
worktree.path
|
|
271
|
+
)
|
|
272
|
+
except GitError:
|
|
273
|
+
pass
|
|
274
|
+
await detail_pane.mount(
|
|
275
|
+
WorktreeDetail(
|
|
276
|
+
repo,
|
|
277
|
+
worktree,
|
|
278
|
+
commit_hash=commit_hash,
|
|
279
|
+
commit_time=commit_time,
|
|
280
|
+
has_remote=has_remote,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
# Fetch sessions in background
|
|
284
|
+
self._fetch_sessions_for_path(worktree.path, "worktree")
|
|
285
|
+
elif selection.repository_id:
|
|
286
|
+
# Show repository detail
|
|
287
|
+
selected_repo = self._state.find_repository(selection.repository_id)
|
|
288
|
+
if selected_repo:
|
|
289
|
+
try:
|
|
290
|
+
branch = await self._git_service.get_current_branch(
|
|
291
|
+
selected_repo.source_path
|
|
292
|
+
)
|
|
293
|
+
except GitError:
|
|
294
|
+
branch = ""
|
|
295
|
+
# Get commit info
|
|
296
|
+
commit_hash = ""
|
|
297
|
+
commit_time = None
|
|
298
|
+
has_remote = False
|
|
299
|
+
try:
|
|
300
|
+
commit_info = await self._git_service.get_latest_commit(
|
|
301
|
+
selected_repo.source_path
|
|
302
|
+
)
|
|
303
|
+
commit_hash = commit_info.short_hash
|
|
304
|
+
commit_time = commit_info.timestamp
|
|
305
|
+
has_remote = await self._git_service.has_remote_tracking(
|
|
306
|
+
selected_repo.source_path
|
|
307
|
+
)
|
|
308
|
+
except GitError:
|
|
309
|
+
pass
|
|
310
|
+
# Mount detail pane immediately, fetch data in background
|
|
311
|
+
detail = RepositoryDetail(
|
|
312
|
+
selected_repo,
|
|
313
|
+
current_branch=branch,
|
|
314
|
+
commit_hash=commit_hash,
|
|
315
|
+
commit_time=commit_time,
|
|
316
|
+
has_remote=has_remote,
|
|
317
|
+
)
|
|
318
|
+
await detail_pane.mount(detail)
|
|
319
|
+
# Start spinner and fetch sessions and issues in background
|
|
320
|
+
detail.start_issues_spinner()
|
|
321
|
+
self._fetch_sessions_for_path(selected_repo.source_path, "repository")
|
|
322
|
+
self._fetch_issues_for_repo(selected_repo.source_path)
|
|
323
|
+
else:
|
|
324
|
+
# Show empty state
|
|
325
|
+
await detail_pane.mount(EmptyState())
|
|
326
|
+
|
|
327
|
+
# Event handlers from sidebar
|
|
328
|
+
async def on_sidebar_repository_selected(
|
|
329
|
+
self, event: Sidebar.RepositorySelected
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Handle repository selection."""
|
|
332
|
+
self._state.select_repository(event.repo_id)
|
|
333
|
+
await self._refresh_detail_pane()
|
|
334
|
+
|
|
335
|
+
async def on_sidebar_worktree_selected(
|
|
336
|
+
self, event: Sidebar.WorktreeSelected
|
|
337
|
+
) -> None:
|
|
338
|
+
"""Handle worktree selection."""
|
|
339
|
+
self._state.select_worktree(event.repo_id, event.worktree_id)
|
|
340
|
+
await self._refresh_detail_pane()
|
|
341
|
+
|
|
342
|
+
def on_sidebar_add_repository_requested(
|
|
343
|
+
self, event: Sidebar.AddRepositoryRequested
|
|
344
|
+
) -> None:
|
|
345
|
+
"""Handle add repository request."""
|
|
346
|
+
self.action_add_repository()
|
|
347
|
+
|
|
348
|
+
async def on_sidebar_add_worktree_requested(
|
|
349
|
+
self, event: Sidebar.AddWorktreeRequested
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Handle add worktree request."""
|
|
352
|
+
await self._show_add_worktree_modal(event.repo_id)
|
|
353
|
+
|
|
354
|
+
# Consolidated event handlers for shared messages (from both detail views)
|
|
355
|
+
def on_open_in_editor(self, event: OpenInEditor) -> None:
|
|
356
|
+
"""Handle open in editor request."""
|
|
357
|
+
self._open_in_editor(event.path)
|
|
358
|
+
|
|
359
|
+
def on_open_in_terminal(self, event: OpenInTerminal) -> None:
|
|
360
|
+
"""Handle open in terminal request."""
|
|
361
|
+
self._open_in_terminal(event.path)
|
|
362
|
+
|
|
363
|
+
def on_open_in_file_manager(self, event: OpenInFileManager) -> None:
|
|
364
|
+
"""Handle open in file manager request."""
|
|
365
|
+
self._open_in_file_manager(event.path)
|
|
366
|
+
|
|
367
|
+
def on_start_claude_session(self, event: StartClaudeSession) -> None:
|
|
368
|
+
"""Handle start Claude session request."""
|
|
369
|
+
self._start_claude_session(event.path)
|
|
370
|
+
|
|
371
|
+
def on_start_claude_yolo_session(self, event: StartClaudeYoloSession) -> None:
|
|
372
|
+
"""Handle start Claude YOLO session request."""
|
|
373
|
+
self._start_claude_session(event.path, yolo=True)
|
|
374
|
+
|
|
375
|
+
def on_continue_claude_session(self, event: ContinueClaudeSession) -> None:
|
|
376
|
+
"""Handle continue Claude session request."""
|
|
377
|
+
self._continue_claude_session(event.session_id, event.path)
|
|
378
|
+
|
|
379
|
+
def on_continue_claude_yolo_session(self, event: ContinueClaudeYoloSession) -> None:
|
|
380
|
+
"""Handle continue Claude YOLO session request."""
|
|
381
|
+
self._continue_claude_session(event.session_id, event.path, yolo=True)
|
|
382
|
+
|
|
383
|
+
@work
|
|
384
|
+
async def on_worktree_detail_sync_requested(
|
|
385
|
+
self, event: WorktreeDetail.SyncRequested
|
|
386
|
+
) -> None:
|
|
387
|
+
"""Handle sync (fetch/pull) request for worktree."""
|
|
388
|
+
self.notify("Syncing...")
|
|
389
|
+
try:
|
|
390
|
+
await self._git_service.pull(event.path)
|
|
391
|
+
self.notify("Sync complete")
|
|
392
|
+
await self._refresh_detail_pane()
|
|
393
|
+
except GitError as e:
|
|
394
|
+
self.notify(f"Sync failed: {e}", severity="error")
|
|
395
|
+
|
|
396
|
+
async def on_repository_detail_add_worktree_requested(
|
|
397
|
+
self, event: RepositoryDetail.AddWorktreeRequested
|
|
398
|
+
) -> None:
|
|
399
|
+
"""Handle add worktree request."""
|
|
400
|
+
await self._show_add_worktree_modal(event.repo_id)
|
|
401
|
+
|
|
402
|
+
def on_configure_claude_command(self, event: ConfigureClaudeCommand) -> None:
|
|
403
|
+
"""Handle configure Claude command request."""
|
|
404
|
+
self._show_claude_command_modal(event.repo_id, event.worktree_id)
|
|
405
|
+
|
|
406
|
+
@work
|
|
407
|
+
async def on_repository_detail_remove_repository_requested(
|
|
408
|
+
self, event: RepositoryDetail.RemoveRepositoryRequested
|
|
409
|
+
) -> None:
|
|
410
|
+
"""Handle remove repository request."""
|
|
411
|
+
repo = self._state.find_repository(event.repo_id)
|
|
412
|
+
if repo:
|
|
413
|
+
confirmed = await self.push_screen_wait(
|
|
414
|
+
ConfirmDeleteModal(
|
|
415
|
+
"Remove Repository",
|
|
416
|
+
f"Remove '{repo.name}' from forestui?\n(Files will not be deleted)",
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
if confirmed:
|
|
420
|
+
self._state.remove_repository(event.repo_id)
|
|
421
|
+
self._refresh_sidebar()
|
|
422
|
+
await self._refresh_detail_pane()
|
|
423
|
+
|
|
424
|
+
@work
|
|
425
|
+
async def on_repository_detail_sync_requested(
|
|
426
|
+
self, event: RepositoryDetail.SyncRequested
|
|
427
|
+
) -> None:
|
|
428
|
+
"""Handle sync (fetch/pull) request."""
|
|
429
|
+
self.notify("Syncing...")
|
|
430
|
+
try:
|
|
431
|
+
await self._git_service.pull(event.path)
|
|
432
|
+
self.notify("Sync complete")
|
|
433
|
+
await self._refresh_detail_pane()
|
|
434
|
+
except GitError as e:
|
|
435
|
+
self.notify(f"Sync failed: {e}", severity="error")
|
|
436
|
+
|
|
437
|
+
async def on_repository_detail_create_worktree_from_issue(
|
|
438
|
+
self, event: RepositoryDetail.CreateWorktreeFromIssue
|
|
439
|
+
) -> None:
|
|
440
|
+
"""Handle create worktree from issue request."""
|
|
441
|
+
await self._show_create_worktree_from_issue_modal(event.repo_id, event.issue)
|
|
442
|
+
|
|
443
|
+
def on_repository_detail_refresh_issues_requested(
|
|
444
|
+
self, event: RepositoryDetail.RefreshIssuesRequested
|
|
445
|
+
) -> None:
|
|
446
|
+
"""Handle manual refresh of GitHub issues."""
|
|
447
|
+
self._github_service.invalidate_cache()
|
|
448
|
+
self._fetch_issues_for_repo(event.repo_path)
|
|
449
|
+
|
|
450
|
+
async def _show_create_worktree_from_issue_modal(
|
|
451
|
+
self, repo_id: UUID, issue: GitHubIssue
|
|
452
|
+
) -> None:
|
|
453
|
+
"""Show the create worktree from issue modal."""
|
|
454
|
+
repo = self._state.find_repository(repo_id)
|
|
455
|
+
if not repo:
|
|
456
|
+
return
|
|
457
|
+
try:
|
|
458
|
+
branches = await self._git_service.list_branches(repo.source_path)
|
|
459
|
+
except GitError:
|
|
460
|
+
branches = []
|
|
461
|
+
settings = self._settings_service.settings
|
|
462
|
+
self.push_screen(
|
|
463
|
+
CreateWorktreeFromIssueModal(
|
|
464
|
+
repo, issue, branches, get_forest_path(), settings.branch_prefix
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
@work
|
|
469
|
+
async def on_create_worktree_from_issue_modal_worktree_created(
|
|
470
|
+
self, event: CreateWorktreeFromIssueModal.WorktreeCreated
|
|
471
|
+
) -> None:
|
|
472
|
+
"""Handle worktree created from issue modal."""
|
|
473
|
+
repo = self._state.find_repository(event.repo_id)
|
|
474
|
+
if not repo:
|
|
475
|
+
return
|
|
476
|
+
|
|
477
|
+
forest_dir = get_forest_path()
|
|
478
|
+
worktree_path = forest_dir / repo.name / event.name
|
|
479
|
+
|
|
480
|
+
try:
|
|
481
|
+
# Pull repo first if requested
|
|
482
|
+
if event.pull_first:
|
|
483
|
+
self.notify("Pulling repo...")
|
|
484
|
+
await self._git_service.pull(repo.source_path)
|
|
485
|
+
|
|
486
|
+
await self._git_service.create_worktree(
|
|
487
|
+
repo.source_path, worktree_path, event.branch, event.new_branch
|
|
488
|
+
)
|
|
489
|
+
worktree = Worktree(
|
|
490
|
+
name=event.name, branch=event.branch, path=str(worktree_path)
|
|
491
|
+
)
|
|
492
|
+
self._state.add_worktree(event.repo_id, worktree)
|
|
493
|
+
self._state.select_worktree(event.repo_id, worktree.id)
|
|
494
|
+
self._refresh_sidebar()
|
|
495
|
+
await self._refresh_detail_pane()
|
|
496
|
+
self.notify(f"Created worktree '{event.name}'")
|
|
497
|
+
except GitError as e:
|
|
498
|
+
self.notify(f"Failed to create worktree: {e}", severity="error")
|
|
499
|
+
|
|
500
|
+
async def on_worktree_detail_archive_worktree_requested(
|
|
501
|
+
self, event: WorktreeDetail.ArchiveWorktreeRequested
|
|
502
|
+
) -> None:
|
|
503
|
+
"""Handle archive worktree request."""
|
|
504
|
+
self._state.archive_worktree(event.worktree_id)
|
|
505
|
+
self._refresh_sidebar()
|
|
506
|
+
await self._refresh_detail_pane()
|
|
507
|
+
|
|
508
|
+
async def on_worktree_detail_unarchive_worktree_requested(
|
|
509
|
+
self, event: WorktreeDetail.UnarchiveWorktreeRequested
|
|
510
|
+
) -> None:
|
|
511
|
+
"""Handle unarchive worktree request."""
|
|
512
|
+
self._state.unarchive_worktree(event.worktree_id)
|
|
513
|
+
self._refresh_sidebar()
|
|
514
|
+
await self._refresh_detail_pane()
|
|
515
|
+
|
|
516
|
+
@work
|
|
517
|
+
async def on_worktree_detail_delete_worktree_requested(
|
|
518
|
+
self, event: WorktreeDetail.DeleteWorktreeRequested
|
|
519
|
+
) -> None:
|
|
520
|
+
"""Handle delete worktree request."""
|
|
521
|
+
result = self._state.find_worktree(event.worktree_id)
|
|
522
|
+
if result:
|
|
523
|
+
repo, worktree = result
|
|
524
|
+
confirmed = await self.push_screen_wait(
|
|
525
|
+
ConfirmDeleteModal(
|
|
526
|
+
"Delete Worktree",
|
|
527
|
+
f"Permanently delete worktree '{worktree.name}'?\nThis cannot be undone.",
|
|
528
|
+
)
|
|
529
|
+
)
|
|
530
|
+
if confirmed:
|
|
531
|
+
with contextlib.suppress(GitError):
|
|
532
|
+
await self._git_service.remove_worktree(
|
|
533
|
+
repo.source_path, worktree.path
|
|
534
|
+
)
|
|
535
|
+
self._state.remove_worktree(event.worktree_id)
|
|
536
|
+
self._refresh_sidebar()
|
|
537
|
+
await self._refresh_detail_pane()
|
|
538
|
+
|
|
539
|
+
async def on_worktree_detail_rename_worktree_requested(
|
|
540
|
+
self, event: WorktreeDetail.RenameWorktreeRequested
|
|
541
|
+
) -> None:
|
|
542
|
+
"""Handle rename worktree request."""
|
|
543
|
+
result = self._state.find_worktree(event.worktree_id)
|
|
544
|
+
if result:
|
|
545
|
+
repo, worktree = result
|
|
546
|
+
old_path = Path(worktree.path)
|
|
547
|
+
new_path = old_path.parent / event.new_name
|
|
548
|
+
|
|
549
|
+
if new_path.exists():
|
|
550
|
+
self.notify("Path already exists", severity="error")
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
# Rename the directory
|
|
555
|
+
old_path.rename(new_path)
|
|
556
|
+
# Repair git references
|
|
557
|
+
await self._git_service.repair_worktree(repo.source_path, new_path)
|
|
558
|
+
# Migrate Claude sessions
|
|
559
|
+
self._claude_service.migrate_sessions(old_path, new_path)
|
|
560
|
+
# Update state
|
|
561
|
+
self._state.update_worktree(
|
|
562
|
+
event.worktree_id, name=event.new_name, path=str(new_path)
|
|
563
|
+
)
|
|
564
|
+
self._refresh_sidebar()
|
|
565
|
+
await self._refresh_detail_pane()
|
|
566
|
+
except (OSError, GitError) as e:
|
|
567
|
+
self.notify(f"Rename failed: {e}", severity="error")
|
|
568
|
+
|
|
569
|
+
async def on_worktree_detail_rename_branch_requested(
|
|
570
|
+
self, event: WorktreeDetail.RenameBranchRequested
|
|
571
|
+
) -> None:
|
|
572
|
+
"""Handle rename branch request."""
|
|
573
|
+
result = self._state.find_worktree(event.worktree_id)
|
|
574
|
+
if result:
|
|
575
|
+
_repo, worktree = result
|
|
576
|
+
try:
|
|
577
|
+
await self._git_service.rename_branch(
|
|
578
|
+
worktree.path, worktree.branch, event.new_branch
|
|
579
|
+
)
|
|
580
|
+
self._state.update_worktree(event.worktree_id, branch=event.new_branch)
|
|
581
|
+
self._refresh_sidebar()
|
|
582
|
+
await self._refresh_detail_pane()
|
|
583
|
+
except GitError as e:
|
|
584
|
+
self.notify(f"Branch rename failed: {e}", severity="error")
|
|
585
|
+
|
|
586
|
+
# Modal handlers
|
|
587
|
+
async def on_add_repository_modal_repository_added(
|
|
588
|
+
self, event: AddRepositoryModal.RepositoryAdded
|
|
589
|
+
) -> None:
|
|
590
|
+
"""Handle repository added from modal."""
|
|
591
|
+
path = Path(event.path)
|
|
592
|
+
repo = Repository(name=path.name, source_path=str(path))
|
|
593
|
+
self._state.add_repository(repo)
|
|
594
|
+
self._state.select_repository(repo.id)
|
|
595
|
+
self._refresh_sidebar()
|
|
596
|
+
await self._refresh_detail_pane()
|
|
597
|
+
|
|
598
|
+
if event.import_worktrees:
|
|
599
|
+
await self._import_existing_worktrees(repo)
|
|
600
|
+
|
|
601
|
+
async def on_add_worktree_modal_worktree_created(
|
|
602
|
+
self, event: AddWorktreeModal.WorktreeCreated
|
|
603
|
+
) -> None:
|
|
604
|
+
"""Handle worktree created from modal."""
|
|
605
|
+
repo = self._state.find_repository(event.repo_id)
|
|
606
|
+
if not repo:
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
forest_dir = get_forest_path()
|
|
610
|
+
worktree_path = forest_dir / repo.name / event.name
|
|
611
|
+
|
|
612
|
+
try:
|
|
613
|
+
await self._git_service.create_worktree(
|
|
614
|
+
repo.source_path, worktree_path, event.branch, event.new_branch
|
|
615
|
+
)
|
|
616
|
+
worktree = Worktree(
|
|
617
|
+
name=event.name, branch=event.branch, path=str(worktree_path)
|
|
618
|
+
)
|
|
619
|
+
self._state.add_worktree(event.repo_id, worktree)
|
|
620
|
+
self._state.select_worktree(event.repo_id, worktree.id)
|
|
621
|
+
self._refresh_sidebar()
|
|
622
|
+
await self._refresh_detail_pane()
|
|
623
|
+
self.notify(f"Created worktree '{event.name}'")
|
|
624
|
+
except GitError as e:
|
|
625
|
+
self.notify(f"Failed to create worktree: {e}", severity="error")
|
|
626
|
+
|
|
627
|
+
# Actions
|
|
628
|
+
def action_add_repository(self) -> None:
|
|
629
|
+
"""Show add repository modal."""
|
|
630
|
+
self.push_screen(AddRepositoryModal())
|
|
631
|
+
|
|
632
|
+
async def action_add_worktree(self) -> None:
|
|
633
|
+
"""Show add worktree modal for selected repository."""
|
|
634
|
+
repo_id = self._state.selection.repository_id
|
|
635
|
+
if repo_id:
|
|
636
|
+
await self._show_add_worktree_modal(repo_id)
|
|
637
|
+
else:
|
|
638
|
+
self.notify("Select a repository first", severity="warning")
|
|
639
|
+
|
|
640
|
+
async def _show_add_worktree_modal(self, repo_id: UUID) -> None:
|
|
641
|
+
"""Show the add worktree modal."""
|
|
642
|
+
repo = self._state.find_repository(repo_id)
|
|
643
|
+
if not repo:
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
try:
|
|
647
|
+
branches = await self._git_service.list_branches(repo.source_path)
|
|
648
|
+
except GitError:
|
|
649
|
+
branches = []
|
|
650
|
+
|
|
651
|
+
settings = self._settings_service.settings
|
|
652
|
+
self.push_screen(
|
|
653
|
+
AddWorktreeModal(
|
|
654
|
+
repo,
|
|
655
|
+
branches,
|
|
656
|
+
get_forest_path(),
|
|
657
|
+
settings.branch_prefix,
|
|
658
|
+
)
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
def action_open_editor(self) -> None:
|
|
662
|
+
"""Open selected item in editor."""
|
|
663
|
+
path = self._get_selected_path()
|
|
664
|
+
if path:
|
|
665
|
+
self._open_in_editor(path)
|
|
666
|
+
|
|
667
|
+
def action_open_terminal(self) -> None:
|
|
668
|
+
"""Open selected item in terminal."""
|
|
669
|
+
path = self._get_selected_path()
|
|
670
|
+
if path:
|
|
671
|
+
self._open_in_terminal(path)
|
|
672
|
+
|
|
673
|
+
def action_open_files(self) -> None:
|
|
674
|
+
"""Open selected item in file manager."""
|
|
675
|
+
path = self._get_selected_path()
|
|
676
|
+
if path:
|
|
677
|
+
self._open_in_file_manager(path)
|
|
678
|
+
|
|
679
|
+
def action_start_claude(self) -> None:
|
|
680
|
+
"""Start Claude session for selected item."""
|
|
681
|
+
path = self._get_selected_path()
|
|
682
|
+
if path:
|
|
683
|
+
self._start_claude_session(path)
|
|
684
|
+
|
|
685
|
+
def action_start_claude_yolo(self) -> None:
|
|
686
|
+
"""Start Claude session with --dangerously-skip-permissions."""
|
|
687
|
+
path = self._get_selected_path()
|
|
688
|
+
if path:
|
|
689
|
+
self._start_claude_session(path, yolo=True)
|
|
690
|
+
|
|
691
|
+
async def action_toggle_archive(self) -> None:
|
|
692
|
+
"""Toggle archive status of selected worktree."""
|
|
693
|
+
if self._state.selection.worktree_id:
|
|
694
|
+
result = self._state.find_worktree(self._state.selection.worktree_id)
|
|
695
|
+
if result:
|
|
696
|
+
_, worktree = result
|
|
697
|
+
if worktree.is_archived:
|
|
698
|
+
self._state.unarchive_worktree(worktree.id)
|
|
699
|
+
else:
|
|
700
|
+
self._state.archive_worktree(worktree.id)
|
|
701
|
+
self._refresh_sidebar()
|
|
702
|
+
await self._refresh_detail_pane()
|
|
703
|
+
|
|
704
|
+
@work
|
|
705
|
+
async def action_delete(self) -> None:
|
|
706
|
+
"""Delete selected item."""
|
|
707
|
+
selection = self._state.selection
|
|
708
|
+
if selection.worktree_id:
|
|
709
|
+
result = self._state.find_worktree(selection.worktree_id)
|
|
710
|
+
if result:
|
|
711
|
+
repo, worktree = result
|
|
712
|
+
confirmed = await self.push_screen_wait(
|
|
713
|
+
ConfirmDeleteModal(
|
|
714
|
+
"Delete Worktree",
|
|
715
|
+
f"Permanently delete '{worktree.name}'?",
|
|
716
|
+
)
|
|
717
|
+
)
|
|
718
|
+
if confirmed:
|
|
719
|
+
with contextlib.suppress(GitError):
|
|
720
|
+
await self._git_service.remove_worktree(
|
|
721
|
+
repo.source_path, worktree.path
|
|
722
|
+
)
|
|
723
|
+
self._state.remove_worktree(worktree.id)
|
|
724
|
+
self._refresh_sidebar()
|
|
725
|
+
await self._refresh_detail_pane()
|
|
726
|
+
elif selection.repository_id:
|
|
727
|
+
selected_repo = self._state.find_repository(selection.repository_id)
|
|
728
|
+
if selected_repo:
|
|
729
|
+
confirmed = await self.push_screen_wait(
|
|
730
|
+
ConfirmDeleteModal(
|
|
731
|
+
"Remove Repository",
|
|
732
|
+
f"Remove '{selected_repo.name}' from forestui?",
|
|
733
|
+
)
|
|
734
|
+
)
|
|
735
|
+
if confirmed:
|
|
736
|
+
self._state.remove_repository(selected_repo.id)
|
|
737
|
+
self._refresh_sidebar()
|
|
738
|
+
await self._refresh_detail_pane()
|
|
739
|
+
|
|
740
|
+
@work
|
|
741
|
+
async def action_open_settings(self) -> None:
|
|
742
|
+
"""Open settings modal."""
|
|
743
|
+
settings = self._settings_service.settings
|
|
744
|
+
result = await self.push_screen_wait(SettingsModal(settings))
|
|
745
|
+
if result:
|
|
746
|
+
self._settings_service.save_settings(result)
|
|
747
|
+
self.notify("Settings saved")
|
|
748
|
+
|
|
749
|
+
async def action_refresh(self) -> None:
|
|
750
|
+
"""Refresh the UI."""
|
|
751
|
+
self._refresh_sidebar()
|
|
752
|
+
await self._refresh_detail_pane()
|
|
753
|
+
|
|
754
|
+
def action_show_help(self) -> None:
|
|
755
|
+
"""Show help information."""
|
|
756
|
+
self.notify(
|
|
757
|
+
"a: Add Repo | w: Add Worktree | e: Editor | t: Terminal | "
|
|
758
|
+
"n: Claude | h: Archive | d: Delete | s: Settings | q: Quit"
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
# Helper methods
|
|
762
|
+
def _get_selected_path(self) -> str | None:
|
|
763
|
+
"""Get the path of the currently selected item."""
|
|
764
|
+
selection = self._state.selection
|
|
765
|
+
if selection.worktree_id:
|
|
766
|
+
result = self._state.find_worktree(selection.worktree_id)
|
|
767
|
+
if result:
|
|
768
|
+
return result[1].path
|
|
769
|
+
elif selection.repository_id:
|
|
770
|
+
repo = self._state.find_repository(selection.repository_id)
|
|
771
|
+
if repo:
|
|
772
|
+
return repo.source_path
|
|
773
|
+
return None
|
|
774
|
+
|
|
775
|
+
def _get_name_for_path(self, path: str) -> str | None:
|
|
776
|
+
"""Get the worktree or repository name for a given path."""
|
|
777
|
+
# First check worktrees
|
|
778
|
+
for repo in self._state.repositories:
|
|
779
|
+
for worktree in repo.worktrees:
|
|
780
|
+
if worktree.path == path:
|
|
781
|
+
return worktree.name
|
|
782
|
+
# Check if it's the repository source path
|
|
783
|
+
if repo.source_path == path:
|
|
784
|
+
return repo.name
|
|
785
|
+
return None
|
|
786
|
+
|
|
787
|
+
def _get_claude_window_name(self, path: str) -> str:
|
|
788
|
+
"""Get the window name for Claude sessions: repo:branch format."""
|
|
789
|
+
# Check worktrees first
|
|
790
|
+
for repo in self._state.repositories:
|
|
791
|
+
for worktree in repo.worktrees:
|
|
792
|
+
if worktree.path == path:
|
|
793
|
+
return f"{repo.name}:{worktree.branch}"
|
|
794
|
+
# Check if it's the repository source path
|
|
795
|
+
if repo.source_path == path:
|
|
796
|
+
return repo.name
|
|
797
|
+
return "session"
|
|
798
|
+
|
|
799
|
+
def _open_in_editor(self, path: str) -> None:
|
|
800
|
+
"""Open path in configured editor."""
|
|
801
|
+
editor = self._settings_service.settings.default_editor
|
|
802
|
+
|
|
803
|
+
# If inside tmux and editor is TUI-based, use tmux window
|
|
804
|
+
if self._tmux_service.is_inside_tmux and self._tmux_service.is_tui_editor(
|
|
805
|
+
editor
|
|
806
|
+
):
|
|
807
|
+
name = self._get_claude_window_name(path)
|
|
808
|
+
if self._tmux_service.create_editor_window(name, path, editor):
|
|
809
|
+
self.notify(f"Opened {editor} in edit:{name}")
|
|
810
|
+
return
|
|
811
|
+
|
|
812
|
+
# GUI editor or not in tmux - spawn normally
|
|
813
|
+
try:
|
|
814
|
+
# Handle editors with arguments (e.g., "emacs -nw")
|
|
815
|
+
editor_parts = editor.split()
|
|
816
|
+
subprocess.Popen(
|
|
817
|
+
[*editor_parts, path],
|
|
818
|
+
stdout=subprocess.DEVNULL,
|
|
819
|
+
stderr=subprocess.DEVNULL,
|
|
820
|
+
)
|
|
821
|
+
self.notify(f"Opened in {editor_parts[0]}")
|
|
822
|
+
except FileNotFoundError:
|
|
823
|
+
self.notify(f"Editor '{editor}' not found", severity="error")
|
|
824
|
+
|
|
825
|
+
def _open_in_terminal(self, path: str) -> None:
|
|
826
|
+
"""Open path in a tmux terminal window."""
|
|
827
|
+
name = self._get_claude_window_name(path)
|
|
828
|
+
if self._tmux_service.create_shell_window(name, path):
|
|
829
|
+
self.notify(f"Opened terminal in term:{name}")
|
|
830
|
+
else:
|
|
831
|
+
self.notify("Failed to create terminal window", severity="error")
|
|
832
|
+
|
|
833
|
+
def _open_in_file_manager(self, path: str) -> None:
|
|
834
|
+
"""Open path in Midnight Commander (mc) in a tmux window."""
|
|
835
|
+
name = self._get_claude_window_name(path)
|
|
836
|
+
if self._tmux_service.create_mc_window(name, path):
|
|
837
|
+
self.notify(f"Opened mc in files:{name}")
|
|
838
|
+
else:
|
|
839
|
+
self.notify("Failed to create mc window", severity="error")
|
|
840
|
+
|
|
841
|
+
@work
|
|
842
|
+
async def _show_claude_command_modal(
|
|
843
|
+
self, repo_id: UUID, worktree_id: UUID | None = None
|
|
844
|
+
) -> None:
|
|
845
|
+
"""Show the Claude command configuration modal."""
|
|
846
|
+
repo = self._state.find_repository(repo_id)
|
|
847
|
+
if not repo:
|
|
848
|
+
return
|
|
849
|
+
|
|
850
|
+
# Determine if configuring worktree or repository
|
|
851
|
+
if worktree_id:
|
|
852
|
+
worktree = repo.find_worktree(worktree_id)
|
|
853
|
+
if not worktree:
|
|
854
|
+
return
|
|
855
|
+
name = f"{repo.name}:{worktree.name}"
|
|
856
|
+
current_command = worktree.custom_claude_command
|
|
857
|
+
is_worktree = True
|
|
858
|
+
else:
|
|
859
|
+
name = repo.name
|
|
860
|
+
current_command = repo.custom_claude_command
|
|
861
|
+
is_worktree = False
|
|
862
|
+
|
|
863
|
+
result: ClaudeCommandResult = await self.push_screen_wait(
|
|
864
|
+
ClaudeCommandModal(name, current_command, is_worktree=is_worktree)
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
if result.cancelled:
|
|
868
|
+
return
|
|
869
|
+
|
|
870
|
+
# Update the appropriate level
|
|
871
|
+
if worktree_id:
|
|
872
|
+
self._state.update_worktree_command(worktree_id, result.command)
|
|
873
|
+
target = f"{repo.name}:{worktree.name}" # type: ignore[union-attr]
|
|
874
|
+
else:
|
|
875
|
+
self._state.update_repository_command(repo_id, result.command)
|
|
876
|
+
target = repo.name
|
|
877
|
+
|
|
878
|
+
await self._refresh_detail_pane()
|
|
879
|
+
if result.command:
|
|
880
|
+
self.notify(f"Custom Claude command set for {target}")
|
|
881
|
+
else:
|
|
882
|
+
self.notify(f"Custom Claude command cleared for {target}")
|
|
883
|
+
|
|
884
|
+
def _find_repo_for_path(self, path: str) -> Repository | None:
|
|
885
|
+
"""Find the repository associated with a path (worktree or source)."""
|
|
886
|
+
for repo in self._state.repositories:
|
|
887
|
+
if repo.source_path == path:
|
|
888
|
+
return repo
|
|
889
|
+
for worktree in repo.worktrees:
|
|
890
|
+
if worktree.path == path:
|
|
891
|
+
return repo
|
|
892
|
+
return None
|
|
893
|
+
|
|
894
|
+
def _resolve_claude_command(self, path: str) -> str | None:
|
|
895
|
+
"""Resolve Claude command with hierarchy: worktree > repo > folder > default.
|
|
896
|
+
|
|
897
|
+
Args:
|
|
898
|
+
path: The path to check for associated repository/worktree
|
|
899
|
+
|
|
900
|
+
Returns:
|
|
901
|
+
Custom command if set, None to use default "claude"
|
|
902
|
+
"""
|
|
903
|
+
# Check worktree-level and repo-level
|
|
904
|
+
for repo in self._state.repositories:
|
|
905
|
+
# Check worktrees first (most specific)
|
|
906
|
+
for worktree in repo.worktrees:
|
|
907
|
+
if worktree.path == path:
|
|
908
|
+
if worktree.custom_claude_command:
|
|
909
|
+
return worktree.custom_claude_command
|
|
910
|
+
if repo.custom_claude_command:
|
|
911
|
+
return repo.custom_claude_command
|
|
912
|
+
break
|
|
913
|
+
# Check repository source path
|
|
914
|
+
if repo.source_path == path:
|
|
915
|
+
if repo.custom_claude_command:
|
|
916
|
+
return repo.custom_claude_command
|
|
917
|
+
break
|
|
918
|
+
|
|
919
|
+
# Fall back to folder-level setting
|
|
920
|
+
settings = self._settings_service.settings
|
|
921
|
+
return settings.custom_claude_command
|
|
922
|
+
|
|
923
|
+
def _start_claude_session(self, path: str, yolo: bool = False) -> None:
|
|
924
|
+
"""Start a new Claude session in a tmux window."""
|
|
925
|
+
name = self._get_claude_window_name(path)
|
|
926
|
+
custom_command = self._resolve_claude_command(path)
|
|
927
|
+
window_name = self._tmux_service.create_claude_window(
|
|
928
|
+
name, path, yolo=yolo, custom_command=custom_command
|
|
929
|
+
)
|
|
930
|
+
if window_name:
|
|
931
|
+
mode = " (YOLO)" if yolo else ""
|
|
932
|
+
self.notify(f"Started Claude{mode} in {window_name}")
|
|
933
|
+
else:
|
|
934
|
+
self.notify("Failed to create Claude window", severity="error")
|
|
935
|
+
|
|
936
|
+
def _continue_claude_session(
|
|
937
|
+
self, session_id: str, path: str, yolo: bool = False
|
|
938
|
+
) -> None:
|
|
939
|
+
"""Continue an existing Claude session in a tmux window."""
|
|
940
|
+
name = self._get_claude_window_name(path)
|
|
941
|
+
custom_command = self._resolve_claude_command(path)
|
|
942
|
+
window_name = self._tmux_service.create_claude_window(
|
|
943
|
+
name,
|
|
944
|
+
path,
|
|
945
|
+
resume_session_id=session_id,
|
|
946
|
+
yolo=yolo,
|
|
947
|
+
custom_command=custom_command,
|
|
948
|
+
)
|
|
949
|
+
if window_name:
|
|
950
|
+
mode = " (YOLO)" if yolo else ""
|
|
951
|
+
self.notify(f"Resuming Claude{mode} in {window_name}")
|
|
952
|
+
else:
|
|
953
|
+
self.notify("Failed to create Claude window", severity="error")
|
|
954
|
+
|
|
955
|
+
async def _import_existing_worktrees(self, repo: Repository) -> None:
|
|
956
|
+
"""Import existing worktrees from a repository."""
|
|
957
|
+
try:
|
|
958
|
+
worktrees = await self._git_service.list_worktrees(repo.source_path)
|
|
959
|
+
forest_dir = get_forest_path()
|
|
960
|
+
|
|
961
|
+
for wt_info in worktrees:
|
|
962
|
+
# Skip the main worktree (same as source_path)
|
|
963
|
+
if Path(wt_info.path).resolve() == Path(repo.source_path).resolve():
|
|
964
|
+
continue
|
|
965
|
+
|
|
966
|
+
# Check if already in forest directory
|
|
967
|
+
wt_path = Path(wt_info.path)
|
|
968
|
+
if str(wt_path).startswith(str(forest_dir)):
|
|
969
|
+
continue
|
|
970
|
+
|
|
971
|
+
# Create worktree model
|
|
972
|
+
name = wt_path.name
|
|
973
|
+
branch = wt_info.branch or "HEAD"
|
|
974
|
+
worktree = Worktree(name=name, branch=branch, path=str(wt_path))
|
|
975
|
+
self._state.add_worktree(repo.id, worktree)
|
|
976
|
+
|
|
977
|
+
self._refresh_sidebar()
|
|
978
|
+
self.notify(f"Imported {len(worktrees) - 1} worktrees")
|
|
979
|
+
except GitError as e:
|
|
980
|
+
self.notify(f"Failed to import worktrees: {e}", severity="error")
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def run_app() -> None:
|
|
984
|
+
"""Run the forestui application."""
|
|
985
|
+
import sys
|
|
986
|
+
import traceback
|
|
987
|
+
from pathlib import Path
|
|
988
|
+
|
|
989
|
+
try:
|
|
990
|
+
app = ForestApp()
|
|
991
|
+
app.run()
|
|
992
|
+
except Exception as e:
|
|
993
|
+
error_log = Path.home() / ".forestui-error.log"
|
|
994
|
+
tb = traceback.format_exc()
|
|
995
|
+
error_log.write_text(tb)
|
|
996
|
+
print(tb, file=sys.stderr)
|
|
997
|
+
print(f"\nError: {e}", file=sys.stderr)
|
|
998
|
+
print(f"\nError log written to: {error_log}", file=sys.stderr)
|
|
999
|
+
input("Press Enter to exit...")
|
|
1000
|
+
sys.exit(1)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
# Entry point for CLI
|
|
1004
|
+
def main() -> None:
|
|
1005
|
+
"""CLI entry point - delegates to cli module."""
|
|
1006
|
+
from forestui.cli import main as cli_main
|
|
1007
|
+
|
|
1008
|
+
cli_main()
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
if __name__ == "__main__":
|
|
1012
|
+
main()
|