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,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
+ ]