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/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()