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.
@@ -0,0 +1,377 @@
1
+ """Repository detail view component."""
2
+
3
+ from datetime import datetime
4
+ from uuid import UUID
5
+
6
+ import humanize
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Horizontal, Vertical
9
+ from textual.message import Message
10
+ from textual.timer import Timer
11
+ from textual.widget import Widget
12
+ from textual.widgets import Button, Label, Rule
13
+
14
+ from forestui.components.messages import (
15
+ ConfigureClaudeCommand,
16
+ ContinueClaudeSession,
17
+ ContinueClaudeYoloSession,
18
+ OpenInEditor,
19
+ OpenInFileManager,
20
+ OpenInTerminal,
21
+ StartClaudeSession,
22
+ StartClaudeYoloSession,
23
+ )
24
+ from forestui.models import ClaudeSession, GitHubIssue, Repository
25
+
26
+
27
+ class RepositoryDetail(Widget):
28
+ """Detail view for a selected repository."""
29
+
30
+ class AddWorktreeRequested(Message):
31
+ """Request to add a worktree."""
32
+
33
+ def __init__(self, repo_id: UUID) -> None:
34
+ self.repo_id = repo_id
35
+ super().__init__()
36
+
37
+ class RemoveRepositoryRequested(Message):
38
+ """Request to remove repository."""
39
+
40
+ def __init__(self, repo_id: UUID) -> None:
41
+ self.repo_id = repo_id
42
+ super().__init__()
43
+
44
+ class SyncRequested(Message):
45
+ """Request to sync (fetch/pull) the repository."""
46
+
47
+ def __init__(self, repo_id: UUID, path: str) -> None:
48
+ self.repo_id = repo_id
49
+ self.path = path
50
+ super().__init__()
51
+
52
+ class CreateWorktreeFromIssue(Message):
53
+ """Request to create worktree from GitHub issue."""
54
+
55
+ def __init__(self, repo_id: UUID, issue: GitHubIssue) -> None:
56
+ self.repo_id = repo_id
57
+ self.issue = issue
58
+ super().__init__()
59
+
60
+ class RefreshIssuesRequested(Message):
61
+ """Request to refresh GitHub issues."""
62
+
63
+ def __init__(self, repo_path: str) -> None:
64
+ self.repo_path = repo_path
65
+ super().__init__()
66
+
67
+ def __init__(
68
+ self,
69
+ repository: Repository,
70
+ current_branch: str = "",
71
+ commit_hash: str = "",
72
+ commit_time: datetime | None = None,
73
+ has_remote: bool = True,
74
+ ) -> None:
75
+ super().__init__()
76
+ self._repository = repository
77
+ self._current_branch = current_branch
78
+ self._commit_hash = commit_hash
79
+ self._commit_time = commit_time
80
+ self._has_remote = has_remote
81
+ self._sessions: list[ClaudeSession] = []
82
+ self._issues: list[GitHubIssue] = []
83
+ self._issues_by_number: dict[int, GitHubIssue] = {}
84
+ self._spinner_chars = "|/-\\"
85
+ self._spinner_index = 0
86
+ self._spinner_timer: Timer | None = None
87
+
88
+ def compose(self) -> ComposeResult:
89
+ """Compose the repository detail view."""
90
+ with Vertical(classes="detail-content"):
91
+ # Header - Main Repository
92
+ with Vertical(classes="detail-header"):
93
+ yield Label("MAIN REPOSITORY", classes="section-header")
94
+ yield Label(
95
+ f"Repository: {self._repository.name}",
96
+ classes="detail-title",
97
+ )
98
+ if self._current_branch:
99
+ yield Label(
100
+ f"Branch: {self._current_branch}",
101
+ classes="label-accent",
102
+ )
103
+ # Commit info
104
+ if self._commit_hash:
105
+ relative_time = (
106
+ humanize.naturaltime(self._commit_time)
107
+ if self._commit_time
108
+ else ""
109
+ )
110
+ commit_text = f"Commit: {self._commit_hash}"
111
+ if relative_time:
112
+ commit_text += f" ({relative_time})"
113
+ yield Label(commit_text, classes="label-muted")
114
+ # Sync button
115
+ with Horizontal(classes="action-row"):
116
+ if self._has_remote:
117
+ yield Button("⟳ Git Pull", id="btn-sync", variant="default")
118
+ else:
119
+ yield Button(
120
+ "⟳ Git Pull (No remote)",
121
+ id="btn-sync",
122
+ variant="default",
123
+ disabled=True,
124
+ )
125
+
126
+ yield Rule()
127
+
128
+ # Location section
129
+ yield Label("LOCATION", classes="section-header")
130
+ yield Label(
131
+ self._repository.source_path,
132
+ classes="path-display label-secondary",
133
+ )
134
+
135
+ yield Rule()
136
+
137
+ # Actions section
138
+ yield Label("OPEN IN", classes="section-header")
139
+ with Horizontal(classes="action-row"):
140
+ yield Button(" Editor", id="btn-editor", variant="default")
141
+ yield Button(" Terminal", id="btn-terminal", variant="default")
142
+ yield Button(" Files", id="btn-files", variant="default")
143
+
144
+ yield Rule()
145
+
146
+ # Claude section
147
+ yield Label("CLAUDE", classes="section-header")
148
+ with Horizontal(classes="action-row"):
149
+ yield Button("New Session", id="btn-claude-new", variant="primary")
150
+ yield Button(
151
+ "New Session: YOLO",
152
+ id="btn-claude-yolo",
153
+ variant="error",
154
+ classes="-destructive",
155
+ )
156
+ yield Button(" Add Worktree", id="btn-add-worktree", variant="default")
157
+
158
+ # Sessions list (loaded async)
159
+ yield Label("RECENT SESSIONS", classes="section-header")
160
+ with Vertical(id="sessions-container"):
161
+ yield Label("Loading...", classes="label-muted")
162
+
163
+ # GitHub Issues section (loaded async)
164
+ yield Rule()
165
+ with Horizontal(classes="section-header-row"):
166
+ yield Label("MY OPEN GITHUB ISSUES", classes="section-header")
167
+ yield Button("↻", id="btn-refresh-issues", classes="refresh-btn")
168
+ with Vertical(id="issues-container"):
169
+ yield Label("Loading...", classes="label-muted")
170
+
171
+ yield Rule()
172
+
173
+ # Manage section
174
+ yield Label("MANAGE", classes="section-header")
175
+ with Horizontal(classes="action-row"):
176
+ yield Button(
177
+ " Custom Claude Command",
178
+ id="btn-configure-claude",
179
+ variant="default",
180
+ )
181
+ yield Button(
182
+ " Remove Repository",
183
+ id="btn-remove-repo",
184
+ variant="error",
185
+ classes="-destructive",
186
+ )
187
+
188
+ def on_button_pressed(self, event: Button.Pressed) -> None:
189
+ """Handle button presses."""
190
+ path = self._repository.source_path
191
+ btn_id = event.button.id or ""
192
+
193
+ match btn_id:
194
+ case "btn-editor":
195
+ self.post_message(OpenInEditor(path))
196
+ case "btn-terminal":
197
+ self.post_message(OpenInTerminal(path))
198
+ case "btn-files":
199
+ self.post_message(OpenInFileManager(path))
200
+ case "btn-claude-new":
201
+ self.post_message(StartClaudeSession(path))
202
+ case "btn-claude-yolo":
203
+ self.post_message(StartClaudeYoloSession(path))
204
+ case "btn-configure-claude":
205
+ self.post_message(ConfigureClaudeCommand(self._repository.id))
206
+ case "btn-add-worktree":
207
+ self.post_message(self.AddWorktreeRequested(self._repository.id))
208
+ case "btn-remove-repo":
209
+ self.post_message(self.RemoveRepositoryRequested(self._repository.id))
210
+ case "btn-sync":
211
+ self.post_message(
212
+ self.SyncRequested(
213
+ self._repository.id, self._repository.source_path
214
+ )
215
+ )
216
+ case "btn-refresh-issues":
217
+ self._start_refresh_spinner()
218
+ self.post_message(
219
+ self.RefreshIssuesRequested(self._repository.source_path)
220
+ )
221
+ case _ if btn_id.startswith("btn-resume-"):
222
+ session_id = btn_id.replace("btn-resume-", "")
223
+ self.post_message(ContinueClaudeSession(session_id, path))
224
+ case _ if btn_id.startswith("btn-yolo-"):
225
+ session_id = btn_id.replace("btn-yolo-", "")
226
+ self.post_message(ContinueClaudeYoloSession(session_id, path))
227
+ case _ if btn_id.startswith("btn-issue-"):
228
+ issue_num = int(btn_id.replace("btn-issue-", ""))
229
+ issue = self._issues_by_number.get(issue_num)
230
+ if issue:
231
+ self.post_message(
232
+ self.CreateWorktreeFromIssue(self._repository.id, issue)
233
+ )
234
+
235
+ def update_sessions(self, sessions: list[ClaudeSession]) -> None:
236
+ """Update the sessions section with fetched sessions."""
237
+ self._sessions = sessions
238
+
239
+ try:
240
+ container = self.query_one("#sessions-container", Vertical)
241
+ container.remove_children()
242
+
243
+ if sessions:
244
+ for session in sessions[:5]:
245
+ title_display = session.title[:60] + (
246
+ "..." if len(session.title) > 60 else ""
247
+ )
248
+
249
+ # Build session info widgets
250
+ info_children: list[Label] = [
251
+ Label(title_display, classes="session-title")
252
+ ]
253
+
254
+ if session.last_message and session.last_message != session.title:
255
+ last_display = session.last_message[:40] + (
256
+ "..." if len(session.last_message) > 40 else ""
257
+ )
258
+ info_children.append(
259
+ Label(
260
+ f"> {last_display}",
261
+ classes="session-last label-secondary",
262
+ )
263
+ )
264
+
265
+ meta = f"{session.relative_time} • {session.message_count} msgs"
266
+ info_children.append(
267
+ Label(meta, classes="session-meta label-muted")
268
+ )
269
+
270
+ row = Vertical(
271
+ Horizontal(
272
+ Vertical(*info_children, classes="session-info"),
273
+ Horizontal(
274
+ Button(
275
+ "Resume",
276
+ id=f"btn-resume-{session.id}",
277
+ variant="default",
278
+ classes="session-btn",
279
+ ),
280
+ Button(
281
+ "YOLO",
282
+ id=f"btn-yolo-{session.id}",
283
+ variant="error",
284
+ classes="session-btn -destructive",
285
+ ),
286
+ classes="session-buttons",
287
+ ),
288
+ classes="session-header-row",
289
+ ),
290
+ classes="session-item",
291
+ )
292
+ container.mount(row)
293
+ else:
294
+ container.mount(Label("No sessions found", classes="label-muted"))
295
+ except Exception:
296
+ pass # Widget may have been removed
297
+
298
+ def start_issues_spinner(self) -> None:
299
+ """Start the refresh button spinner animation (public for initial load)."""
300
+ self._start_refresh_spinner()
301
+
302
+ def _start_refresh_spinner(self) -> None:
303
+ """Start the refresh button spinner animation."""
304
+ # Don't start if already spinning
305
+ if self._spinner_timer is not None:
306
+ return
307
+ self._spinner_index = 0
308
+ try:
309
+ btn = self.query_one("#btn-refresh-issues", Button)
310
+ btn.label = self._spinner_chars[0]
311
+ btn.disabled = True
312
+ self._spinner_timer = self.set_interval(0.05, self._tick_spinner)
313
+ except Exception:
314
+ pass
315
+
316
+ def _tick_spinner(self) -> None:
317
+ """Advance the spinner animation."""
318
+ self._spinner_index = (self._spinner_index + 1) % len(self._spinner_chars)
319
+ try:
320
+ btn = self.query_one("#btn-refresh-issues", Button)
321
+ btn.label = self._spinner_chars[self._spinner_index]
322
+ except Exception:
323
+ self._stop_refresh_spinner()
324
+
325
+ def _stop_refresh_spinner(self) -> None:
326
+ """Stop the spinner and restore the refresh icon."""
327
+ if self._spinner_timer:
328
+ self._spinner_timer.stop()
329
+ self._spinner_timer = None
330
+ try:
331
+ btn = self.query_one("#btn-refresh-issues", Button)
332
+ btn.label = "↻"
333
+ btn.disabled = False
334
+ except Exception:
335
+ pass
336
+
337
+ def update_issues(self, issues: list[GitHubIssue]) -> None:
338
+ """Update the issues section with fetched issues."""
339
+ self._stop_refresh_spinner()
340
+ self._issues = issues
341
+ self._issues_by_number = {i.number: i for i in issues}
342
+
343
+ try:
344
+ container = self.query_one("#issues-container", Vertical)
345
+ container.remove_children()
346
+
347
+ if issues:
348
+ for issue in issues[:5]:
349
+ title_text = issue.title[:45] + (
350
+ "..." if len(issue.title) > 45 else ""
351
+ )
352
+ labels_str = ", ".join(lbl.name for lbl in issue.labels[:2])
353
+ meta = f"{issue.relative_time}"
354
+ if labels_str:
355
+ meta += f" \u2022 {labels_str}"
356
+
357
+ # Compose widgets using Textual's compose pattern
358
+ row = Horizontal(
359
+ Vertical(
360
+ Label(
361
+ f"#{issue.number} {title_text}", classes="issue-title"
362
+ ),
363
+ Label(meta, classes="issue-meta label-muted"),
364
+ classes="issue-info",
365
+ ),
366
+ Button(
367
+ "Create WT",
368
+ id=f"btn-issue-{issue.number}",
369
+ classes="issue-btn",
370
+ ),
371
+ classes="issue-row",
372
+ )
373
+ container.mount(row)
374
+ else:
375
+ container.mount(Label("No issues found", classes="label-muted"))
376
+ except Exception:
377
+ pass # Widget may have been removed
@@ -0,0 +1,256 @@
1
+ """Sidebar component for forestui."""
2
+
3
+ from uuid import UUID
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.containers import Vertical
8
+ from textual.message import Message
9
+ from textual.widgets import Button, Label, Static, Tree
10
+
11
+ from forestui.models import Repository, Worktree
12
+
13
+
14
+ class RepoNode:
15
+ """Data for a repository node."""
16
+
17
+ def __init__(self, repo: Repository) -> None:
18
+ self.repo = repo
19
+ self.id = repo.id
20
+
21
+
22
+ class WorktreeNode:
23
+ """Data for a worktree node."""
24
+
25
+ def __init__(self, repo: Repository, worktree: Worktree) -> None:
26
+ self.repo = repo
27
+ self.worktree = worktree
28
+ self.repo_id = repo.id
29
+ self.id = worktree.id
30
+
31
+
32
+ class ArchivedNode:
33
+ """Data for the archived section node."""
34
+
35
+ pass
36
+
37
+
38
+ class Sidebar(Static):
39
+ """Sidebar widget with repository and worktree tree."""
40
+
41
+ BINDINGS = [
42
+ Binding("a", "add_repository", "Add Repo", show=True),
43
+ ]
44
+
45
+ class RepositorySelected(Message):
46
+ """Sent when a repository is selected."""
47
+
48
+ def __init__(self, repo_id: UUID) -> None:
49
+ self.repo_id = repo_id
50
+ super().__init__()
51
+
52
+ class WorktreeSelected(Message):
53
+ """Sent when a worktree is selected."""
54
+
55
+ def __init__(self, repo_id: UUID, worktree_id: UUID) -> None:
56
+ self.repo_id = repo_id
57
+ self.worktree_id = worktree_id
58
+ super().__init__()
59
+
60
+ class AddRepositoryRequested(Message):
61
+ """Sent when user wants to add a repository."""
62
+
63
+ pass
64
+
65
+ class AddWorktreeRequested(Message):
66
+ """Sent when user wants to add a worktree."""
67
+
68
+ def __init__(self, repo_id: UUID) -> None:
69
+ self.repo_id = repo_id
70
+ super().__init__()
71
+
72
+ class DeleteRepositoryRequested(Message):
73
+ """Sent when user wants to delete a repository."""
74
+
75
+ def __init__(self, repo_id: UUID) -> None:
76
+ self.repo_id = repo_id
77
+ super().__init__()
78
+
79
+ class ArchiveWorktreeRequested(Message):
80
+ """Sent when user wants to archive a worktree."""
81
+
82
+ def __init__(self, worktree_id: UUID) -> None:
83
+ self.worktree_id = worktree_id
84
+ super().__init__()
85
+
86
+ class UnarchiveWorktreeRequested(Message):
87
+ """Sent when user wants to unarchive a worktree."""
88
+
89
+ def __init__(self, worktree_id: UUID) -> None:
90
+ self.worktree_id = worktree_id
91
+ super().__init__()
92
+
93
+ class DeleteWorktreeRequested(Message):
94
+ """Sent when user wants to delete a worktree."""
95
+
96
+ def __init__(self, repo_id: UUID, worktree_id: UUID) -> None:
97
+ self.repo_id = repo_id
98
+ self.worktree_id = worktree_id
99
+ super().__init__()
100
+
101
+ def __init__(
102
+ self,
103
+ repositories: list[Repository],
104
+ selected_repo_id: UUID | None = None,
105
+ selected_worktree_id: UUID | None = None,
106
+ show_archived: bool = False,
107
+ ) -> None:
108
+ super().__init__(id="sidebar") # Apply sidebar ID to the widget itself
109
+ self._repositories = repositories
110
+ self._selected_repo_id = selected_repo_id
111
+ self._selected_worktree_id = selected_worktree_id
112
+ self._show_archived = show_archived
113
+ self._last_selected_repo_id: UUID | None = None
114
+ self._gh_status: str = "..."
115
+
116
+ def compose(self) -> ComposeResult:
117
+ """Compose the sidebar UI."""
118
+ # App header box
119
+ with Vertical(id="sidebar-header-box"):
120
+ yield Label("forestui", id="sidebar-title")
121
+ yield Label(f"gh cli: {self._gh_status}", id="gh-status")
122
+ # Tree view
123
+ tree: Tree[RepoNode | WorktreeNode | ArchivedNode] = Tree(
124
+ "Repositories", id="repo-tree"
125
+ )
126
+ tree.show_root = False
127
+ tree.guide_depth = 2
128
+ yield tree
129
+
130
+ def on_mount(self) -> None:
131
+ """Populate the tree when mounted."""
132
+ self._populate_tree()
133
+
134
+ def _populate_tree(self) -> None:
135
+ """Populate the tree with repositories and worktrees."""
136
+ tree = self.query_one("#repo-tree", Tree)
137
+ tree.clear()
138
+
139
+ for repo in self._repositories:
140
+ # Add repository node
141
+ repo_label = f" {repo.name}"
142
+ repo_node = tree.root.add(repo_label, data=RepoNode(repo), expand=True)
143
+
144
+ # Add active worktrees
145
+ for worktree in repo.active_worktrees():
146
+ prefix = "├─" if worktree != repo.active_worktrees()[-1] else "└─"
147
+ wt_label = f"{prefix} {worktree.name} [{worktree.branch}]"
148
+ repo_node.add_leaf(wt_label, data=WorktreeNode(repo, worktree))
149
+
150
+ # Add archived section if there are archived worktrees
151
+ if self._show_archived:
152
+ has_archived = any(
153
+ w.is_archived for r in self._repositories for w in r.worktrees
154
+ )
155
+ if has_archived:
156
+ archived_node = tree.root.add(
157
+ " Archived", data=ArchivedNode(), expand=False
158
+ )
159
+ for repo in self._repositories:
160
+ for worktree in repo.archived_worktrees():
161
+ wt_label = f" {worktree.name} ({repo.name})"
162
+ archived_node.add_leaf(
163
+ wt_label, data=WorktreeNode(repo, worktree)
164
+ )
165
+
166
+ def update_repositories(
167
+ self,
168
+ repositories: list[Repository],
169
+ selected_repo_id: UUID | None = None,
170
+ selected_worktree_id: UUID | None = None,
171
+ show_archived: bool = False,
172
+ ) -> None:
173
+ """Update the sidebar with new data."""
174
+ self._repositories = repositories
175
+ self._selected_repo_id = selected_repo_id
176
+ self._selected_worktree_id = selected_worktree_id
177
+ self._show_archived = show_archived
178
+ self._populate_tree()
179
+
180
+ def on_tree_node_selected(
181
+ self, event: Tree.NodeSelected[RepoNode | WorktreeNode | ArchivedNode]
182
+ ) -> None:
183
+ """Handle tree node selection (Enter key or click)."""
184
+ node = event.node
185
+ data = node.data
186
+
187
+ # Smart collapse: only collapse if clicking on already-selected repo
188
+ if isinstance(data, RepoNode):
189
+ was_already_selected = self._last_selected_repo_id == data.id
190
+ if not was_already_selected and not node.is_expanded:
191
+ # Re-expand: user clicked to select, not to collapse
192
+ node.expand()
193
+ self._last_selected_repo_id = data.id
194
+ elif isinstance(data, WorktreeNode):
195
+ # Clicking a worktree clears the "last selected repo" tracking
196
+ self._last_selected_repo_id = None
197
+
198
+ self._select_node(node)
199
+
200
+ def on_tree_node_highlighted(
201
+ self, event: Tree.NodeHighlighted[RepoNode | WorktreeNode | ArchivedNode]
202
+ ) -> None:
203
+ """Handle tree node highlight (keyboard navigation)."""
204
+ self._select_node(event.node)
205
+
206
+ def _select_node(self, node: object) -> None:
207
+ """Select a node and post the appropriate message."""
208
+ if node is None:
209
+ return
210
+
211
+ data = getattr(node, "data", None)
212
+ if data is None:
213
+ return
214
+
215
+ if isinstance(data, RepoNode):
216
+ self.post_message(self.RepositorySelected(data.id))
217
+ elif isinstance(data, WorktreeNode):
218
+ self.post_message(self.WorktreeSelected(data.repo_id, data.id))
219
+
220
+ def on_button_pressed(self, event: Button.Pressed) -> None:
221
+ """Handle button presses."""
222
+ if event.button.id == "btn-add-repo":
223
+ self.post_message(self.AddRepositoryRequested())
224
+
225
+ def action_add_repository(self) -> None:
226
+ """Action to add a repository."""
227
+ self.post_message(self.AddRepositoryRequested())
228
+
229
+ def set_gh_status(self, status: str, username: str | None = None) -> None:
230
+ """Update GitHub CLI status display."""
231
+ # Map to shorter display text
232
+ if status == "authenticated" and username:
233
+ display_text = f"ok ({username})"
234
+ elif status == "authenticated":
235
+ display_text = "ok"
236
+ elif status == "not_authenticated":
237
+ display_text = "unauth'd"
238
+ elif status == "not_installed":
239
+ display_text = "missing"
240
+ else:
241
+ display_text = status
242
+ self._gh_status = display_text
243
+
244
+ try:
245
+ label = self.query_one("#gh-status", Label)
246
+ label.update(f"gh cli: {display_text}")
247
+ # Update styling class
248
+ label.remove_class("gh-status-ok", "gh-status-warn", "gh-status-error")
249
+ if status == "authenticated":
250
+ label.add_class("gh-status-ok")
251
+ elif status == "not_authenticated":
252
+ label.add_class("gh-status-warn")
253
+ else:
254
+ label.add_class("gh-status-error")
255
+ except Exception:
256
+ pass