forestui 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forestui/__init__.py +3 -0
- forestui/__main__.py +6 -0
- forestui/app.py +1012 -0
- forestui/cli.py +169 -0
- forestui/components/__init__.py +21 -0
- forestui/components/messages.py +76 -0
- forestui/components/modals.py +668 -0
- forestui/components/repository_detail.py +377 -0
- forestui/components/sidebar.py +256 -0
- forestui/components/worktree_detail.py +326 -0
- forestui/models.py +221 -0
- forestui/services/__init__.py +16 -0
- forestui/services/claude_session.py +179 -0
- forestui/services/git.py +254 -0
- forestui/services/github.py +242 -0
- forestui/services/settings.py +84 -0
- forestui/services/tmux.py +320 -0
- forestui/state.py +248 -0
- forestui/theme.py +657 -0
- forestui-0.9.0.dist-info/METADATA +152 -0
- forestui-0.9.0.dist-info/RECORD +23 -0
- forestui-0.9.0.dist-info/WHEEL +4 -0
- forestui-0.9.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,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
|