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,326 @@
|
|
|
1
|
+
"""Worktree 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.widget import Widget
|
|
11
|
+
from textual.widgets import Button, Input, Label, Rule
|
|
12
|
+
|
|
13
|
+
from forestui.components.messages import (
|
|
14
|
+
ConfigureClaudeCommand,
|
|
15
|
+
ContinueClaudeSession,
|
|
16
|
+
ContinueClaudeYoloSession,
|
|
17
|
+
OpenInEditor,
|
|
18
|
+
OpenInFileManager,
|
|
19
|
+
OpenInTerminal,
|
|
20
|
+
StartClaudeSession,
|
|
21
|
+
StartClaudeYoloSession,
|
|
22
|
+
)
|
|
23
|
+
from forestui.models import ClaudeSession, Repository, Worktree
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WorktreeDetail(Widget):
|
|
27
|
+
"""Detail view for a selected worktree."""
|
|
28
|
+
|
|
29
|
+
class ArchiveWorktreeRequested(Message):
|
|
30
|
+
"""Request to archive the worktree."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, worktree_id: UUID) -> None:
|
|
33
|
+
self.worktree_id = worktree_id
|
|
34
|
+
super().__init__()
|
|
35
|
+
|
|
36
|
+
class UnarchiveWorktreeRequested(Message):
|
|
37
|
+
"""Request to unarchive the worktree."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, worktree_id: UUID) -> None:
|
|
40
|
+
self.worktree_id = worktree_id
|
|
41
|
+
super().__init__()
|
|
42
|
+
|
|
43
|
+
class DeleteWorktreeRequested(Message):
|
|
44
|
+
"""Request to delete the worktree."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, repo_id: UUID, worktree_id: UUID) -> None:
|
|
47
|
+
self.repo_id = repo_id
|
|
48
|
+
self.worktree_id = worktree_id
|
|
49
|
+
super().__init__()
|
|
50
|
+
|
|
51
|
+
class RenameWorktreeRequested(Message):
|
|
52
|
+
"""Request to rename the worktree."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, worktree_id: UUID, new_name: str) -> None:
|
|
55
|
+
self.worktree_id = worktree_id
|
|
56
|
+
self.new_name = new_name
|
|
57
|
+
super().__init__()
|
|
58
|
+
|
|
59
|
+
class RenameBranchRequested(Message):
|
|
60
|
+
"""Request to rename the branch."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, worktree_id: UUID, new_branch: str) -> None:
|
|
63
|
+
self.worktree_id = worktree_id
|
|
64
|
+
self.new_branch = new_branch
|
|
65
|
+
super().__init__()
|
|
66
|
+
|
|
67
|
+
class SyncRequested(Message):
|
|
68
|
+
"""Request to sync (fetch/pull) the worktree."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, worktree_id: UUID, path: str) -> None:
|
|
71
|
+
self.worktree_id = worktree_id
|
|
72
|
+
self.path = path
|
|
73
|
+
super().__init__()
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
repository: Repository,
|
|
78
|
+
worktree: Worktree,
|
|
79
|
+
commit_hash: str = "",
|
|
80
|
+
commit_time: datetime | None = None,
|
|
81
|
+
has_remote: bool = True,
|
|
82
|
+
) -> None:
|
|
83
|
+
super().__init__()
|
|
84
|
+
self._repository = repository
|
|
85
|
+
self._worktree = worktree
|
|
86
|
+
self._commit_hash = commit_hash
|
|
87
|
+
self._commit_time = commit_time
|
|
88
|
+
self._has_remote = has_remote
|
|
89
|
+
self._sessions: list[ClaudeSession] = []
|
|
90
|
+
|
|
91
|
+
def compose(self) -> ComposeResult:
|
|
92
|
+
"""Compose the worktree detail view."""
|
|
93
|
+
with Vertical(classes="detail-content"):
|
|
94
|
+
# Header - Worktree
|
|
95
|
+
with Vertical(classes="detail-header"):
|
|
96
|
+
yield Label("WORKTREE", classes="section-header")
|
|
97
|
+
yield Label(
|
|
98
|
+
f"Repository: {self._repository.name}",
|
|
99
|
+
classes="detail-title",
|
|
100
|
+
)
|
|
101
|
+
yield Label(
|
|
102
|
+
f"Worktree: {self._worktree.name}",
|
|
103
|
+
classes="label-primary",
|
|
104
|
+
)
|
|
105
|
+
yield Label(
|
|
106
|
+
f"Branch: {self._worktree.branch}",
|
|
107
|
+
classes="label-accent",
|
|
108
|
+
)
|
|
109
|
+
# Commit info
|
|
110
|
+
if self._commit_hash:
|
|
111
|
+
relative_time = (
|
|
112
|
+
humanize.naturaltime(self._commit_time)
|
|
113
|
+
if self._commit_time
|
|
114
|
+
else ""
|
|
115
|
+
)
|
|
116
|
+
commit_text = f"Commit: {self._commit_hash}"
|
|
117
|
+
if relative_time:
|
|
118
|
+
commit_text += f" ({relative_time})"
|
|
119
|
+
yield Label(commit_text, classes="label-muted")
|
|
120
|
+
# Sync button
|
|
121
|
+
with Horizontal(classes="action-row"):
|
|
122
|
+
if self._has_remote:
|
|
123
|
+
yield Button("⟳ Git Pull", id="btn-sync", variant="default")
|
|
124
|
+
else:
|
|
125
|
+
yield Button(
|
|
126
|
+
"⟳ Git Pull (No remote)",
|
|
127
|
+
id="btn-sync",
|
|
128
|
+
variant="default",
|
|
129
|
+
disabled=True,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
yield Rule()
|
|
133
|
+
|
|
134
|
+
# Location section
|
|
135
|
+
yield Label("LOCATION", classes="section-header")
|
|
136
|
+
yield Label(
|
|
137
|
+
self._worktree.path,
|
|
138
|
+
classes="path-display label-secondary",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
yield Rule()
|
|
142
|
+
|
|
143
|
+
# Actions section
|
|
144
|
+
yield Label("OPEN IN", classes="section-header")
|
|
145
|
+
with Horizontal(classes="action-row"):
|
|
146
|
+
yield Button(" Editor", id="btn-editor", variant="default")
|
|
147
|
+
yield Button(" Terminal", id="btn-terminal", variant="default")
|
|
148
|
+
yield Button(" Files", id="btn-files", variant="default")
|
|
149
|
+
|
|
150
|
+
yield Rule()
|
|
151
|
+
|
|
152
|
+
# Claude section
|
|
153
|
+
yield Label("CLAUDE", classes="section-header")
|
|
154
|
+
with Horizontal(classes="action-row"):
|
|
155
|
+
yield Button("New Session", id="btn-claude-new", variant="primary")
|
|
156
|
+
yield Button(
|
|
157
|
+
"New Session: YOLO",
|
|
158
|
+
id="btn-claude-yolo",
|
|
159
|
+
variant="error",
|
|
160
|
+
classes="-destructive",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Sessions list (loaded async)
|
|
164
|
+
yield Label("RECENT SESSIONS", classes="section-header")
|
|
165
|
+
with Vertical(id="sessions-container"):
|
|
166
|
+
yield Label("Loading...", classes="label-muted")
|
|
167
|
+
|
|
168
|
+
yield Rule()
|
|
169
|
+
|
|
170
|
+
# Rename section
|
|
171
|
+
yield Label("RENAME", classes="section-header")
|
|
172
|
+
with Horizontal(classes="action-row"):
|
|
173
|
+
yield Input(
|
|
174
|
+
value=self._worktree.name,
|
|
175
|
+
placeholder="Worktree name",
|
|
176
|
+
id="input-worktree-name",
|
|
177
|
+
)
|
|
178
|
+
with Horizontal(classes="action-row"):
|
|
179
|
+
yield Input(
|
|
180
|
+
value=self._worktree.branch,
|
|
181
|
+
placeholder="Branch name",
|
|
182
|
+
id="input-branch-name",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
yield Rule()
|
|
186
|
+
|
|
187
|
+
# Manage section
|
|
188
|
+
yield Label("MANAGE", classes="section-header")
|
|
189
|
+
with Horizontal(classes="action-row"):
|
|
190
|
+
yield Button(
|
|
191
|
+
" Custom Claude Command",
|
|
192
|
+
id="btn-configure-claude",
|
|
193
|
+
variant="default",
|
|
194
|
+
)
|
|
195
|
+
if self._worktree.is_archived:
|
|
196
|
+
yield Button(
|
|
197
|
+
" Unarchive",
|
|
198
|
+
id="btn-unarchive",
|
|
199
|
+
variant="default",
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
yield Button(
|
|
203
|
+
" Archive",
|
|
204
|
+
id="btn-archive",
|
|
205
|
+
variant="default",
|
|
206
|
+
)
|
|
207
|
+
yield Button(
|
|
208
|
+
" Delete",
|
|
209
|
+
id="btn-delete",
|
|
210
|
+
variant="error",
|
|
211
|
+
classes="-destructive",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
215
|
+
"""Handle button presses."""
|
|
216
|
+
path = self._worktree.path
|
|
217
|
+
btn_id = event.button.id or ""
|
|
218
|
+
|
|
219
|
+
match btn_id:
|
|
220
|
+
case "btn-editor":
|
|
221
|
+
self.post_message(OpenInEditor(path))
|
|
222
|
+
case "btn-terminal":
|
|
223
|
+
self.post_message(OpenInTerminal(path))
|
|
224
|
+
case "btn-files":
|
|
225
|
+
self.post_message(OpenInFileManager(path))
|
|
226
|
+
case "btn-claude-new":
|
|
227
|
+
self.post_message(StartClaudeSession(path))
|
|
228
|
+
case "btn-claude-yolo":
|
|
229
|
+
self.post_message(StartClaudeYoloSession(path))
|
|
230
|
+
case "btn-configure-claude":
|
|
231
|
+
self.post_message(
|
|
232
|
+
ConfigureClaudeCommand(self._repository.id, self._worktree.id)
|
|
233
|
+
)
|
|
234
|
+
case "btn-archive":
|
|
235
|
+
self.post_message(self.ArchiveWorktreeRequested(self._worktree.id))
|
|
236
|
+
case "btn-unarchive":
|
|
237
|
+
self.post_message(self.UnarchiveWorktreeRequested(self._worktree.id))
|
|
238
|
+
case "btn-delete":
|
|
239
|
+
self.post_message(
|
|
240
|
+
self.DeleteWorktreeRequested(self._repository.id, self._worktree.id)
|
|
241
|
+
)
|
|
242
|
+
case "btn-sync":
|
|
243
|
+
self.post_message(self.SyncRequested(self._worktree.id, path))
|
|
244
|
+
case _ if btn_id.startswith("btn-resume-"):
|
|
245
|
+
session_id = btn_id.replace("btn-resume-", "")
|
|
246
|
+
self.post_message(ContinueClaudeSession(session_id, path))
|
|
247
|
+
case _ if btn_id.startswith("btn-yolo-"):
|
|
248
|
+
session_id = btn_id.replace("btn-yolo-", "")
|
|
249
|
+
self.post_message(ContinueClaudeYoloSession(session_id, path))
|
|
250
|
+
|
|
251
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
252
|
+
"""Handle input submission."""
|
|
253
|
+
match event.input.id:
|
|
254
|
+
case "input-worktree-name":
|
|
255
|
+
if event.value and event.value != self._worktree.name:
|
|
256
|
+
self.post_message(
|
|
257
|
+
self.RenameWorktreeRequested(self._worktree.id, event.value)
|
|
258
|
+
)
|
|
259
|
+
case "input-branch-name":
|
|
260
|
+
if event.value and event.value != self._worktree.branch:
|
|
261
|
+
self.post_message(
|
|
262
|
+
self.RenameBranchRequested(self._worktree.id, event.value)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def update_sessions(self, sessions: list[ClaudeSession]) -> None:
|
|
266
|
+
"""Update the sessions section with fetched sessions."""
|
|
267
|
+
self._sessions = sessions
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
container = self.query_one("#sessions-container", Vertical)
|
|
271
|
+
container.remove_children()
|
|
272
|
+
|
|
273
|
+
if sessions:
|
|
274
|
+
for session in sessions[:5]:
|
|
275
|
+
title_display = session.title[:60] + (
|
|
276
|
+
"..." if len(session.title) > 60 else ""
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Build session info widgets
|
|
280
|
+
info_children: list[Label] = [
|
|
281
|
+
Label(title_display, classes="session-title")
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
if session.last_message and session.last_message != session.title:
|
|
285
|
+
last_display = session.last_message[:40] + (
|
|
286
|
+
"..." if len(session.last_message) > 40 else ""
|
|
287
|
+
)
|
|
288
|
+
info_children.append(
|
|
289
|
+
Label(
|
|
290
|
+
f"> {last_display}",
|
|
291
|
+
classes="session-last label-secondary",
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
meta = f"{session.relative_time} • {session.message_count} msgs"
|
|
296
|
+
info_children.append(
|
|
297
|
+
Label(meta, classes="session-meta label-muted")
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
row = Vertical(
|
|
301
|
+
Horizontal(
|
|
302
|
+
Vertical(*info_children, classes="session-info"),
|
|
303
|
+
Horizontal(
|
|
304
|
+
Button(
|
|
305
|
+
"Resume",
|
|
306
|
+
id=f"btn-resume-{session.id}",
|
|
307
|
+
variant="default",
|
|
308
|
+
classes="session-btn",
|
|
309
|
+
),
|
|
310
|
+
Button(
|
|
311
|
+
"YOLO",
|
|
312
|
+
id=f"btn-yolo-{session.id}",
|
|
313
|
+
variant="error",
|
|
314
|
+
classes="session-btn -destructive",
|
|
315
|
+
),
|
|
316
|
+
classes="session-buttons",
|
|
317
|
+
),
|
|
318
|
+
classes="session-header-row",
|
|
319
|
+
),
|
|
320
|
+
classes="session-item",
|
|
321
|
+
)
|
|
322
|
+
container.mount(row)
|
|
323
|
+
else:
|
|
324
|
+
container.mount(Label("No sessions found", classes="label-muted"))
|
|
325
|
+
except Exception:
|
|
326
|
+
pass # Widget may have been removed
|
forestui/models.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Data models for forestui."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Self
|
|
8
|
+
from uuid import UUID, uuid4
|
|
9
|
+
|
|
10
|
+
import humanize
|
|
11
|
+
from pydantic import BaseModel, Field, field_validator
|
|
12
|
+
|
|
13
|
+
# Constants for validation
|
|
14
|
+
MAX_CLAUDE_COMMAND_LENGTH = 200
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_claude_command(command: str) -> str | None:
|
|
18
|
+
"""Validate a custom Claude command.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
command: The command string to validate (can be empty).
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Error message if invalid, None if valid.
|
|
25
|
+
"""
|
|
26
|
+
if not command:
|
|
27
|
+
return None # Empty is valid (clears/uses default)
|
|
28
|
+
|
|
29
|
+
if len(command) > MAX_CLAUDE_COMMAND_LENGTH:
|
|
30
|
+
return f"Command too long (max {MAX_CLAUDE_COMMAND_LENGTH} characters)"
|
|
31
|
+
|
|
32
|
+
if any(c in command for c in "\n\r\t\0"):
|
|
33
|
+
return "Command cannot contain newlines or control characters"
|
|
34
|
+
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _validate_command_field(v: str | None) -> str | None:
|
|
39
|
+
"""Pydantic field validator for custom_claude_command."""
|
|
40
|
+
if v is None:
|
|
41
|
+
return None
|
|
42
|
+
error = validate_claude_command(v)
|
|
43
|
+
if error:
|
|
44
|
+
raise ValueError(error)
|
|
45
|
+
return v
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ClaudeCommandResult:
|
|
50
|
+
"""Result from ClaudeCommandModal.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
command: The command string, or None to clear the setting.
|
|
54
|
+
cancelled: True if user cancelled the modal.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
command: str | None
|
|
58
|
+
cancelled: bool = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Worktree(BaseModel):
|
|
62
|
+
"""Represents a Git worktree."""
|
|
63
|
+
|
|
64
|
+
id: UUID = Field(default_factory=uuid4)
|
|
65
|
+
name: str
|
|
66
|
+
branch: str
|
|
67
|
+
path: str
|
|
68
|
+
is_archived: bool = False
|
|
69
|
+
sort_order: int | None = None
|
|
70
|
+
last_modified: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
71
|
+
custom_claude_command: str | None = None
|
|
72
|
+
|
|
73
|
+
@field_validator("custom_claude_command")
|
|
74
|
+
@classmethod
|
|
75
|
+
def validate_command(cls, v: str | None) -> str | None:
|
|
76
|
+
"""Validate custom_claude_command field."""
|
|
77
|
+
return _validate_command_field(v)
|
|
78
|
+
|
|
79
|
+
def get_path(self) -> Path:
|
|
80
|
+
"""Get the worktree path as a Path object."""
|
|
81
|
+
return Path(self.path).expanduser()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Repository(BaseModel):
|
|
85
|
+
"""Represents a Git repository with its worktrees."""
|
|
86
|
+
|
|
87
|
+
id: UUID = Field(default_factory=uuid4)
|
|
88
|
+
name: str
|
|
89
|
+
source_path: str
|
|
90
|
+
worktrees: list[Worktree] = Field(default_factory=list)
|
|
91
|
+
custom_claude_command: str | None = None
|
|
92
|
+
|
|
93
|
+
@field_validator("custom_claude_command")
|
|
94
|
+
@classmethod
|
|
95
|
+
def validate_command(cls, v: str | None) -> str | None:
|
|
96
|
+
"""Validate custom_claude_command field."""
|
|
97
|
+
return _validate_command_field(v)
|
|
98
|
+
|
|
99
|
+
def get_source_path(self) -> Path:
|
|
100
|
+
"""Get the source path as a Path object."""
|
|
101
|
+
return Path(self.source_path).expanduser()
|
|
102
|
+
|
|
103
|
+
def active_worktrees(self) -> list[Worktree]:
|
|
104
|
+
"""Get active (non-archived) worktrees sorted by order/recency."""
|
|
105
|
+
active = [w for w in self.worktrees if not w.is_archived]
|
|
106
|
+
return sorted(
|
|
107
|
+
active,
|
|
108
|
+
key=lambda w: (
|
|
109
|
+
w.sort_order if w.sort_order is not None else float("inf"),
|
|
110
|
+
-w.last_modified.timestamp(),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def archived_worktrees(self) -> list[Worktree]:
|
|
115
|
+
"""Get archived worktrees sorted by recency."""
|
|
116
|
+
archived = [w for w in self.worktrees if w.is_archived]
|
|
117
|
+
return sorted(archived, key=lambda w: -w.last_modified.timestamp())
|
|
118
|
+
|
|
119
|
+
def find_worktree(self, worktree_id: UUID) -> Worktree | None:
|
|
120
|
+
"""Find a worktree by ID."""
|
|
121
|
+
for w in self.worktrees:
|
|
122
|
+
if w.id == worktree_id:
|
|
123
|
+
return w
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ClaudeSession(BaseModel):
|
|
128
|
+
"""Represents a Claude Code session."""
|
|
129
|
+
|
|
130
|
+
id: str
|
|
131
|
+
title: str
|
|
132
|
+
last_message: str = ""
|
|
133
|
+
last_timestamp: datetime
|
|
134
|
+
message_count: int
|
|
135
|
+
git_branches: list[str] = Field(default_factory=list)
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def relative_time(self) -> str:
|
|
139
|
+
"""Get a human-readable relative time string."""
|
|
140
|
+
return humanize.naturaltime(self.last_timestamp)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def primary_branch(self) -> str | None:
|
|
144
|
+
"""Get the first git branch if available."""
|
|
145
|
+
return self.git_branches[0] if self.git_branches else None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Settings(BaseModel):
|
|
149
|
+
"""Application settings."""
|
|
150
|
+
|
|
151
|
+
default_editor: str = "vim"
|
|
152
|
+
default_terminal: str = ""
|
|
153
|
+
branch_prefix: str = "feat/"
|
|
154
|
+
theme: str = "system"
|
|
155
|
+
custom_claude_command: str | None = None
|
|
156
|
+
|
|
157
|
+
@field_validator("custom_claude_command")
|
|
158
|
+
@classmethod
|
|
159
|
+
def validate_command(cls, v: str | None) -> str | None:
|
|
160
|
+
"""Validate custom_claude_command field."""
|
|
161
|
+
return _validate_command_field(v)
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def default(cls) -> Self:
|
|
165
|
+
"""Create default settings."""
|
|
166
|
+
return cls()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class Selection(BaseModel):
|
|
170
|
+
"""Represents the current selection state."""
|
|
171
|
+
|
|
172
|
+
repository_id: UUID | None = None
|
|
173
|
+
worktree_id: UUID | None = None
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def is_repository(self) -> bool:
|
|
177
|
+
"""Check if a repository is selected (not a worktree)."""
|
|
178
|
+
return self.repository_id is not None and self.worktree_id is None
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def is_worktree(self) -> bool:
|
|
182
|
+
"""Check if a worktree is selected."""
|
|
183
|
+
return self.worktree_id is not None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class GitHubLabel(BaseModel):
|
|
187
|
+
"""GitHub issue label."""
|
|
188
|
+
|
|
189
|
+
name: str
|
|
190
|
+
color: str = ""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class GitHubUser(BaseModel):
|
|
194
|
+
"""GitHub user."""
|
|
195
|
+
|
|
196
|
+
login: str
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class GitHubIssue(BaseModel):
|
|
200
|
+
"""GitHub issue."""
|
|
201
|
+
|
|
202
|
+
number: int
|
|
203
|
+
title: str
|
|
204
|
+
state: str
|
|
205
|
+
url: str
|
|
206
|
+
created_at: datetime
|
|
207
|
+
updated_at: datetime
|
|
208
|
+
author: GitHubUser
|
|
209
|
+
assignees: list[GitHubUser] = Field(default_factory=list)
|
|
210
|
+
labels: list[GitHubLabel] = Field(default_factory=list)
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def branch_name(self) -> str:
|
|
214
|
+
"""Generate branch-safe name from issue. e.g., '42-fix-login-bug'."""
|
|
215
|
+
slug = re.sub(r"[^a-z0-9]+", "-", self.title.lower())[:40].strip("-")
|
|
216
|
+
return f"{self.number}-{slug}"
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def relative_time(self) -> str:
|
|
220
|
+
"""Human-readable relative time since update."""
|
|
221
|
+
return humanize.naturaltime(self.updated_at)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Services for forestui."""
|
|
2
|
+
|
|
3
|
+
from forestui.services.claude_session import ClaudeSessionService
|
|
4
|
+
from forestui.services.git import GitService
|
|
5
|
+
from forestui.services.github import GitHubService, get_github_service
|
|
6
|
+
from forestui.services.settings import SettingsService
|
|
7
|
+
from forestui.services.tmux import TmuxService
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ClaudeSessionService",
|
|
11
|
+
"GitHubService",
|
|
12
|
+
"GitService",
|
|
13
|
+
"SettingsService",
|
|
14
|
+
"TmuxService",
|
|
15
|
+
"get_github_service",
|
|
16
|
+
]
|