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,668 @@
|
|
|
1
|
+
"""Modal dialog components for forestui."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
8
|
+
from textual.message import Message
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Button, Checkbox, Input, Label, Select
|
|
11
|
+
|
|
12
|
+
from forestui.models import (
|
|
13
|
+
MAX_CLAUDE_COMMAND_LENGTH,
|
|
14
|
+
ClaudeCommandResult,
|
|
15
|
+
GitHubIssue,
|
|
16
|
+
Repository,
|
|
17
|
+
Settings,
|
|
18
|
+
validate_claude_command,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AddRepositoryModal(ModalScreen[str | None]):
|
|
23
|
+
"""Modal for adding a new repository."""
|
|
24
|
+
|
|
25
|
+
BINDINGS = [
|
|
26
|
+
("escape", "cancel", "Cancel"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
class RepositoryAdded(Message):
|
|
30
|
+
"""Sent when a repository is added."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, path: str, import_worktrees: bool = False) -> None:
|
|
33
|
+
self.path = path
|
|
34
|
+
self.import_worktrees = import_worktrees
|
|
35
|
+
super().__init__()
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
super().__init__()
|
|
39
|
+
self._path: str = ""
|
|
40
|
+
self._error: str = ""
|
|
41
|
+
self._import_worktrees: bool = False
|
|
42
|
+
|
|
43
|
+
def compose(self) -> ComposeResult:
|
|
44
|
+
"""Compose the modal UI."""
|
|
45
|
+
with Vertical(classes="modal-container"):
|
|
46
|
+
yield Label(" Add Repository", classes="modal-title")
|
|
47
|
+
|
|
48
|
+
yield Label("Repository Path", classes="section-header")
|
|
49
|
+
yield Input(
|
|
50
|
+
placeholder="Enter path or paste from clipboard...",
|
|
51
|
+
id="input-path",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
yield Label("", id="label-status", classes="label-secondary")
|
|
55
|
+
|
|
56
|
+
yield Checkbox("Import existing worktrees", id="checkbox-import")
|
|
57
|
+
|
|
58
|
+
with Horizontal(classes="modal-buttons"):
|
|
59
|
+
yield Button("Cancel", id="btn-cancel", variant="default")
|
|
60
|
+
yield Button("Add Repository", id="btn-add", variant="primary")
|
|
61
|
+
|
|
62
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
63
|
+
"""Handle path input changes."""
|
|
64
|
+
if event.input.id == "input-path":
|
|
65
|
+
self._path = event.value
|
|
66
|
+
self._validate_path()
|
|
67
|
+
|
|
68
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
69
|
+
"""Handle enter key in input."""
|
|
70
|
+
if event.input.id == "input-path":
|
|
71
|
+
self._add_repository()
|
|
72
|
+
|
|
73
|
+
def _validate_path(self) -> None:
|
|
74
|
+
"""Validate the entered path."""
|
|
75
|
+
status_label = self.query_one("#label-status", Label)
|
|
76
|
+
|
|
77
|
+
if not self._path:
|
|
78
|
+
status_label.update("")
|
|
79
|
+
status_label.remove_class("label-destructive")
|
|
80
|
+
status_label.add_class("label-secondary")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
path = Path(self._path).expanduser()
|
|
84
|
+
|
|
85
|
+
if not path.exists():
|
|
86
|
+
status_label.update("Path does not exist")
|
|
87
|
+
status_label.remove_class("label-secondary")
|
|
88
|
+
status_label.add_class("label-destructive")
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
if not path.is_dir():
|
|
92
|
+
status_label.update("Path is not a directory")
|
|
93
|
+
status_label.remove_class("label-secondary")
|
|
94
|
+
status_label.add_class("label-destructive")
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
# Check if it's a git repository
|
|
98
|
+
git_dir = path / ".git"
|
|
99
|
+
if not git_dir.exists():
|
|
100
|
+
status_label.update("Not a git repository")
|
|
101
|
+
status_label.remove_class("label-secondary")
|
|
102
|
+
status_label.add_class("label-destructive")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
status_label.update(f"Repository: {path.name}")
|
|
106
|
+
status_label.remove_class("label-destructive")
|
|
107
|
+
status_label.add_class("label-secondary")
|
|
108
|
+
|
|
109
|
+
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
|
110
|
+
"""Handle checkbox changes."""
|
|
111
|
+
if event.checkbox.id == "checkbox-import":
|
|
112
|
+
self._import_worktrees = event.value
|
|
113
|
+
|
|
114
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
115
|
+
"""Handle button presses."""
|
|
116
|
+
if event.button.id == "btn-cancel":
|
|
117
|
+
self.dismiss(None)
|
|
118
|
+
elif event.button.id == "btn-add":
|
|
119
|
+
self._add_repository()
|
|
120
|
+
|
|
121
|
+
def _add_repository(self) -> None:
|
|
122
|
+
"""Add the repository if valid."""
|
|
123
|
+
if not self._path:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
path = Path(self._path).expanduser()
|
|
127
|
+
if not path.exists() or not (path / ".git").exists():
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
self.post_message(self.RepositoryAdded(str(path), self._import_worktrees))
|
|
131
|
+
self.dismiss(str(path))
|
|
132
|
+
|
|
133
|
+
def action_cancel(self) -> None:
|
|
134
|
+
"""Cancel and close the modal."""
|
|
135
|
+
self.dismiss(None)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class AddWorktreeModal(ModalScreen[tuple[str, str, bool] | None]):
|
|
139
|
+
"""Modal for adding a new worktree."""
|
|
140
|
+
|
|
141
|
+
BINDINGS = [
|
|
142
|
+
("escape", "cancel", "Cancel"),
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
class WorktreeCreated(Message):
|
|
146
|
+
"""Sent when a worktree is created."""
|
|
147
|
+
|
|
148
|
+
def __init__(
|
|
149
|
+
self, repo_id: UUID, name: str, branch: str, new_branch: bool
|
|
150
|
+
) -> None:
|
|
151
|
+
self.repo_id = repo_id
|
|
152
|
+
self.name = name
|
|
153
|
+
self.branch = branch
|
|
154
|
+
self.new_branch = new_branch
|
|
155
|
+
super().__init__()
|
|
156
|
+
|
|
157
|
+
def __init__(
|
|
158
|
+
self,
|
|
159
|
+
repository: Repository,
|
|
160
|
+
branches: list[str],
|
|
161
|
+
forest_dir: Path,
|
|
162
|
+
branch_prefix: str = "feat/",
|
|
163
|
+
) -> None:
|
|
164
|
+
super().__init__()
|
|
165
|
+
self._repository = repository
|
|
166
|
+
self._branches = branches
|
|
167
|
+
self._forest_dir = forest_dir
|
|
168
|
+
self._branch_prefix = branch_prefix
|
|
169
|
+
self._name: str = ""
|
|
170
|
+
self._branch: str = ""
|
|
171
|
+
self._new_branch: bool = True
|
|
172
|
+
self._error: str = ""
|
|
173
|
+
|
|
174
|
+
def compose(self) -> ComposeResult:
|
|
175
|
+
"""Compose the modal UI."""
|
|
176
|
+
with Vertical(classes="modal-container"):
|
|
177
|
+
yield Label(" Add Worktree", classes="modal-title")
|
|
178
|
+
yield Label(f"to {self._repository.name}", classes="label-secondary")
|
|
179
|
+
|
|
180
|
+
yield Label("Worktree Name", classes="section-header")
|
|
181
|
+
yield Input(
|
|
182
|
+
placeholder="my-feature",
|
|
183
|
+
id="input-name",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
yield Label("", id="label-path-preview", classes="label-muted")
|
|
187
|
+
|
|
188
|
+
yield Label("Branch", classes="section-header")
|
|
189
|
+
with Horizontal(classes="action-row"):
|
|
190
|
+
yield Button(
|
|
191
|
+
" New Branch",
|
|
192
|
+
id="btn-new-branch",
|
|
193
|
+
variant="primary",
|
|
194
|
+
)
|
|
195
|
+
yield Button(
|
|
196
|
+
" Existing",
|
|
197
|
+
id="btn-existing-branch",
|
|
198
|
+
variant="default",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
yield Input(
|
|
202
|
+
placeholder=f"{self._branch_prefix}my-feature",
|
|
203
|
+
id="input-branch",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
yield Select(
|
|
207
|
+
[(b, b) for b in self._branches],
|
|
208
|
+
id="select-branch",
|
|
209
|
+
prompt="Select a branch...",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
yield Label("", id="label-error", classes="label-destructive")
|
|
213
|
+
|
|
214
|
+
with Horizontal(classes="modal-buttons"):
|
|
215
|
+
yield Button("Cancel", id="btn-cancel", variant="default")
|
|
216
|
+
yield Button("Create Worktree", id="btn-create", variant="primary")
|
|
217
|
+
|
|
218
|
+
def on_mount(self) -> None:
|
|
219
|
+
"""Set up initial state."""
|
|
220
|
+
# Hide the select by default
|
|
221
|
+
self.query_one("#select-branch", Select).display = False
|
|
222
|
+
|
|
223
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
224
|
+
"""Handle input changes."""
|
|
225
|
+
if event.input.id == "input-name":
|
|
226
|
+
self._name = self._sanitize_name(event.value)
|
|
227
|
+
self._update_path_preview()
|
|
228
|
+
if self._new_branch:
|
|
229
|
+
# Auto-populate branch name
|
|
230
|
+
branch_input = self.query_one("#input-branch", Input)
|
|
231
|
+
branch_input.value = f"{self._branch_prefix}{self._name}"
|
|
232
|
+
self._branch = branch_input.value
|
|
233
|
+
elif event.input.id == "input-branch":
|
|
234
|
+
self._branch = event.value
|
|
235
|
+
|
|
236
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
237
|
+
"""Handle select changes."""
|
|
238
|
+
if event.select.id == "select-branch" and event.value:
|
|
239
|
+
self._branch = str(event.value)
|
|
240
|
+
|
|
241
|
+
def _sanitize_name(self, name: str) -> str:
|
|
242
|
+
"""Sanitize worktree name to valid characters."""
|
|
243
|
+
return "".join(c for c in name if c.isalnum() or c in "-_")
|
|
244
|
+
|
|
245
|
+
def _update_path_preview(self) -> None:
|
|
246
|
+
"""Update the path preview label."""
|
|
247
|
+
preview = self.query_one("#label-path-preview", Label)
|
|
248
|
+
if self._name:
|
|
249
|
+
path = self._forest_dir / self._repository.name / self._name
|
|
250
|
+
preview.update(f" {path}")
|
|
251
|
+
else:
|
|
252
|
+
preview.update("")
|
|
253
|
+
|
|
254
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
255
|
+
"""Handle button presses."""
|
|
256
|
+
match event.button.id:
|
|
257
|
+
case "btn-cancel":
|
|
258
|
+
self.dismiss(None)
|
|
259
|
+
case "btn-create":
|
|
260
|
+
self._create_worktree()
|
|
261
|
+
case "btn-new-branch":
|
|
262
|
+
self._set_new_branch_mode(True)
|
|
263
|
+
case "btn-existing-branch":
|
|
264
|
+
self._set_new_branch_mode(False)
|
|
265
|
+
|
|
266
|
+
def _set_new_branch_mode(self, new_branch: bool) -> None:
|
|
267
|
+
"""Switch between new branch and existing branch modes."""
|
|
268
|
+
self._new_branch = new_branch
|
|
269
|
+
|
|
270
|
+
# Update button styles
|
|
271
|
+
new_btn = self.query_one("#btn-new-branch", Button)
|
|
272
|
+
existing_btn = self.query_one("#btn-existing-branch", Button)
|
|
273
|
+
branch_input = self.query_one("#input-branch", Input)
|
|
274
|
+
branch_select = self.query_one("#select-branch", Select)
|
|
275
|
+
|
|
276
|
+
if new_branch:
|
|
277
|
+
new_btn.variant = "primary"
|
|
278
|
+
existing_btn.variant = "default"
|
|
279
|
+
branch_input.display = True
|
|
280
|
+
branch_select.display = False
|
|
281
|
+
else:
|
|
282
|
+
new_btn.variant = "default"
|
|
283
|
+
existing_btn.variant = "primary"
|
|
284
|
+
branch_input.display = False
|
|
285
|
+
branch_select.display = True
|
|
286
|
+
|
|
287
|
+
def _create_worktree(self) -> None:
|
|
288
|
+
"""Create the worktree if valid."""
|
|
289
|
+
error_label = self.query_one("#label-error", Label)
|
|
290
|
+
|
|
291
|
+
if not self._name:
|
|
292
|
+
error_label.update(" Name is required")
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
if not self._branch:
|
|
296
|
+
error_label.update(" Branch is required")
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
# Check if worktree path already exists
|
|
300
|
+
path = self._forest_dir / self._repository.name / self._name
|
|
301
|
+
if path.exists():
|
|
302
|
+
error_label.update(" Worktree path already exists")
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
self.post_message(
|
|
306
|
+
self.WorktreeCreated(
|
|
307
|
+
self._repository.id, self._name, self._branch, self._new_branch
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
self.dismiss((self._name, self._branch, self._new_branch))
|
|
311
|
+
|
|
312
|
+
def action_cancel(self) -> None:
|
|
313
|
+
"""Cancel and close the modal."""
|
|
314
|
+
self.dismiss(None)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class SettingsModal(ModalScreen[Settings | None]):
|
|
318
|
+
"""Modal for editing settings."""
|
|
319
|
+
|
|
320
|
+
BINDINGS = [
|
|
321
|
+
("escape", "cancel", "Cancel"),
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
EDITORS = [
|
|
325
|
+
("VS Code", "code"),
|
|
326
|
+
("Cursor", "cursor"),
|
|
327
|
+
("Neovim (tmux)", "nvim"),
|
|
328
|
+
("Vim (tmux)", "vim"),
|
|
329
|
+
("Helix (tmux)", "hx"),
|
|
330
|
+
("Emacs TUI (tmux)", "emacs -nw"),
|
|
331
|
+
("PyCharm", "pycharm"),
|
|
332
|
+
("Sublime Text", "subl"),
|
|
333
|
+
("Nano (tmux)", "nano"),
|
|
334
|
+
("Micro (tmux)", "micro"),
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
THEMES = [
|
|
338
|
+
("System", "system"),
|
|
339
|
+
("Dark", "dark"),
|
|
340
|
+
("Light", "light"),
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
def __init__(self, settings: Settings) -> None:
|
|
344
|
+
super().__init__()
|
|
345
|
+
self._settings = settings
|
|
346
|
+
|
|
347
|
+
def compose(self) -> ComposeResult:
|
|
348
|
+
"""Compose the modal UI."""
|
|
349
|
+
with Vertical(classes="modal-container"):
|
|
350
|
+
yield Label(" Settings", classes="modal-title")
|
|
351
|
+
|
|
352
|
+
with VerticalScroll(classes="modal-scroll"):
|
|
353
|
+
yield Label("DEFAULT EDITOR", classes="section-header")
|
|
354
|
+
yield Select(
|
|
355
|
+
[(name, cmd) for name, cmd in self.EDITORS],
|
|
356
|
+
value=self._settings.default_editor,
|
|
357
|
+
id="select-editor",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
yield Label("BRANCH PREFIX", classes="section-header")
|
|
361
|
+
yield Input(
|
|
362
|
+
value=self._settings.branch_prefix,
|
|
363
|
+
id="input-branch-prefix",
|
|
364
|
+
placeholder="feat/",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
yield Label("THEME", classes="section-header")
|
|
368
|
+
yield Select(
|
|
369
|
+
[(name, value) for name, value in self.THEMES],
|
|
370
|
+
value=self._settings.theme,
|
|
371
|
+
id="select-theme",
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
yield Label("CUSTOM CLAUDE COMMAND", classes="section-header")
|
|
375
|
+
yield Input(
|
|
376
|
+
value=self._settings.custom_claude_command or "",
|
|
377
|
+
id="input-custom-claude-command",
|
|
378
|
+
placeholder="e.g., claude --model opus",
|
|
379
|
+
max_length=MAX_CLAUDE_COMMAND_LENGTH,
|
|
380
|
+
)
|
|
381
|
+
yield Label(
|
|
382
|
+
"Default command for all repos",
|
|
383
|
+
classes="label-muted",
|
|
384
|
+
)
|
|
385
|
+
yield Label(
|
|
386
|
+
"Can be overridden per-repo",
|
|
387
|
+
classes="label-muted",
|
|
388
|
+
)
|
|
389
|
+
yield Label("", id="label-claude-error", classes="label-destructive")
|
|
390
|
+
|
|
391
|
+
with Horizontal(classes="modal-buttons"):
|
|
392
|
+
yield Button("Cancel", id="btn-cancel", variant="default")
|
|
393
|
+
yield Button("Save", id="btn-save", variant="primary")
|
|
394
|
+
|
|
395
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
396
|
+
"""Handle button presses."""
|
|
397
|
+
if event.button.id == "btn-cancel":
|
|
398
|
+
self.dismiss(None)
|
|
399
|
+
elif event.button.id == "btn-save":
|
|
400
|
+
self._save_settings()
|
|
401
|
+
|
|
402
|
+
def _save_settings(self) -> None:
|
|
403
|
+
"""Save the settings."""
|
|
404
|
+
editor_select = self.query_one("#select-editor", Select)
|
|
405
|
+
editor = str(editor_select.value) if editor_select.value else "code"
|
|
406
|
+
branch_prefix = self.query_one("#input-branch-prefix", Input).value
|
|
407
|
+
theme_select = self.query_one("#select-theme", Select)
|
|
408
|
+
theme = str(theme_select.value) if theme_select.value else "system"
|
|
409
|
+
custom_claude_command = (
|
|
410
|
+
self.query_one("#input-custom-claude-command", Input).value.strip() or None
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# Validate custom claude command
|
|
414
|
+
error_label = self.query_one("#label-claude-error", Label)
|
|
415
|
+
if custom_claude_command:
|
|
416
|
+
error = validate_claude_command(custom_claude_command)
|
|
417
|
+
if error:
|
|
418
|
+
error_label.update(f" {error}")
|
|
419
|
+
return
|
|
420
|
+
error_label.update("")
|
|
421
|
+
|
|
422
|
+
new_settings = Settings(
|
|
423
|
+
default_editor=editor,
|
|
424
|
+
branch_prefix=branch_prefix,
|
|
425
|
+
theme=theme,
|
|
426
|
+
custom_claude_command=custom_claude_command,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
self.dismiss(new_settings)
|
|
430
|
+
|
|
431
|
+
def action_cancel(self) -> None:
|
|
432
|
+
"""Cancel and close the modal."""
|
|
433
|
+
self.dismiss(None)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class ConfirmDeleteModal(ModalScreen[bool]):
|
|
437
|
+
"""Modal for confirming deletion."""
|
|
438
|
+
|
|
439
|
+
BINDINGS = [
|
|
440
|
+
("escape", "cancel", "Cancel"),
|
|
441
|
+
]
|
|
442
|
+
|
|
443
|
+
def __init__(self, title: str, message: str) -> None:
|
|
444
|
+
super().__init__()
|
|
445
|
+
self._title = title
|
|
446
|
+
self._message = message
|
|
447
|
+
|
|
448
|
+
def compose(self) -> ComposeResult:
|
|
449
|
+
"""Compose the modal UI."""
|
|
450
|
+
with Vertical(classes="modal-container"):
|
|
451
|
+
yield Label(f" {self._title}", classes="modal-title label-destructive")
|
|
452
|
+
yield Label(self._message, classes="label-secondary")
|
|
453
|
+
|
|
454
|
+
with Horizontal(classes="modal-buttons"):
|
|
455
|
+
yield Button("Cancel", id="btn-cancel", variant="default")
|
|
456
|
+
yield Button(
|
|
457
|
+
"Delete", id="btn-delete", variant="error", classes="-destructive"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
461
|
+
"""Handle button presses."""
|
|
462
|
+
if event.button.id == "btn-cancel":
|
|
463
|
+
self.dismiss(False)
|
|
464
|
+
elif event.button.id == "btn-delete":
|
|
465
|
+
self.dismiss(True)
|
|
466
|
+
|
|
467
|
+
def action_cancel(self) -> None:
|
|
468
|
+
"""Cancel and close the modal."""
|
|
469
|
+
self.dismiss(False)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
class CreateWorktreeFromIssueModal(ModalScreen[tuple[str, str, bool, bool] | None]):
|
|
473
|
+
"""Modal for creating a worktree from a GitHub issue."""
|
|
474
|
+
|
|
475
|
+
BINDINGS = [("escape", "cancel", "Cancel")]
|
|
476
|
+
|
|
477
|
+
class WorktreeCreated(Message):
|
|
478
|
+
"""Worktree creation requested."""
|
|
479
|
+
|
|
480
|
+
def __init__(
|
|
481
|
+
self,
|
|
482
|
+
repo_id: UUID,
|
|
483
|
+
name: str,
|
|
484
|
+
branch: str,
|
|
485
|
+
new_branch: bool,
|
|
486
|
+
pull_first: bool,
|
|
487
|
+
) -> None:
|
|
488
|
+
self.repo_id = repo_id
|
|
489
|
+
self.name = name
|
|
490
|
+
self.branch = branch
|
|
491
|
+
self.new_branch = new_branch
|
|
492
|
+
self.pull_first = pull_first
|
|
493
|
+
super().__init__()
|
|
494
|
+
|
|
495
|
+
def __init__(
|
|
496
|
+
self,
|
|
497
|
+
repository: Repository,
|
|
498
|
+
issue: GitHubIssue,
|
|
499
|
+
branches: list[str],
|
|
500
|
+
forest_dir: Path,
|
|
501
|
+
branch_prefix: str = "feat/",
|
|
502
|
+
) -> None:
|
|
503
|
+
super().__init__()
|
|
504
|
+
self._repository = repository
|
|
505
|
+
self._issue = issue
|
|
506
|
+
self._branches = branches
|
|
507
|
+
self._forest_dir = forest_dir
|
|
508
|
+
self._branch_prefix = branch_prefix
|
|
509
|
+
# Pre-fill from issue
|
|
510
|
+
self._name: str = issue.branch_name
|
|
511
|
+
self._branch: str = f"{branch_prefix}{issue.branch_name}"
|
|
512
|
+
self._pull_first: bool = True
|
|
513
|
+
|
|
514
|
+
def compose(self) -> ComposeResult:
|
|
515
|
+
"""Compose the modal UI."""
|
|
516
|
+
with Vertical(classes="modal-container"):
|
|
517
|
+
yield Label(
|
|
518
|
+
f"Create Worktree from Issue #{self._issue.number}",
|
|
519
|
+
classes="modal-title",
|
|
520
|
+
)
|
|
521
|
+
yield Label(self._issue.title, classes="issue-title-preview label-muted")
|
|
522
|
+
|
|
523
|
+
yield Label("Worktree Name", classes="section-header")
|
|
524
|
+
yield Input(value=self._name, id="input-name", placeholder="worktree-name")
|
|
525
|
+
|
|
526
|
+
path_preview = self._forest_dir / self._repository.name / self._name
|
|
527
|
+
yield Label(
|
|
528
|
+
f"Path: {path_preview}", id="path-preview", classes="label-muted"
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
yield Label("Branch Name", classes="section-header")
|
|
532
|
+
yield Input(
|
|
533
|
+
value=self._branch, id="input-branch", placeholder="feat/branch-name"
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
yield Checkbox("Pull repo before creating", value=True, id="checkbox-pull")
|
|
537
|
+
|
|
538
|
+
with Horizontal(classes="modal-buttons"):
|
|
539
|
+
yield Button("Cancel", id="btn-cancel", variant="default")
|
|
540
|
+
yield Button("Create", id="btn-create", variant="primary")
|
|
541
|
+
|
|
542
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
543
|
+
"""Handle input changes."""
|
|
544
|
+
if event.input.id == "input-name":
|
|
545
|
+
self._name = event.value
|
|
546
|
+
path_preview = self._forest_dir / self._repository.name / self._name
|
|
547
|
+
self.query_one("#path-preview", Label).update(f"Path: {path_preview}")
|
|
548
|
+
elif event.input.id == "input-branch":
|
|
549
|
+
self._branch = event.value
|
|
550
|
+
|
|
551
|
+
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
|
552
|
+
"""Handle checkbox changes."""
|
|
553
|
+
if event.checkbox.id == "checkbox-pull":
|
|
554
|
+
self._pull_first = event.value
|
|
555
|
+
|
|
556
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
557
|
+
"""Handle button presses."""
|
|
558
|
+
if event.button.id == "btn-cancel":
|
|
559
|
+
self.dismiss(None)
|
|
560
|
+
elif event.button.id == "btn-create" and self._name and self._branch:
|
|
561
|
+
self.post_message(
|
|
562
|
+
self.WorktreeCreated(
|
|
563
|
+
self._repository.id,
|
|
564
|
+
self._name,
|
|
565
|
+
self._branch,
|
|
566
|
+
True,
|
|
567
|
+
self._pull_first,
|
|
568
|
+
)
|
|
569
|
+
)
|
|
570
|
+
self.dismiss((self._name, self._branch, True, self._pull_first))
|
|
571
|
+
|
|
572
|
+
def action_cancel(self) -> None:
|
|
573
|
+
"""Cancel and close the modal."""
|
|
574
|
+
self.dismiss(None)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
class ClaudeCommandModal(ModalScreen[ClaudeCommandResult]):
|
|
578
|
+
"""Modal for editing custom Claude command for a repository or worktree."""
|
|
579
|
+
|
|
580
|
+
BINDINGS = [
|
|
581
|
+
("escape", "cancel", "Cancel"),
|
|
582
|
+
]
|
|
583
|
+
|
|
584
|
+
# Common command presets
|
|
585
|
+
PRESETS = [
|
|
586
|
+
("claude", "claude"),
|
|
587
|
+
("opus", "claude --model opus"),
|
|
588
|
+
("sonnet", "claude --model sonnet"),
|
|
589
|
+
]
|
|
590
|
+
|
|
591
|
+
def __init__(
|
|
592
|
+
self,
|
|
593
|
+
name: str,
|
|
594
|
+
current_command: str | None,
|
|
595
|
+
*,
|
|
596
|
+
is_worktree: bool = False,
|
|
597
|
+
) -> None:
|
|
598
|
+
super().__init__()
|
|
599
|
+
self._name = name
|
|
600
|
+
self._current_command = current_command
|
|
601
|
+
self._is_worktree = is_worktree
|
|
602
|
+
|
|
603
|
+
def compose(self) -> ComposeResult:
|
|
604
|
+
"""Compose the modal UI."""
|
|
605
|
+
with Vertical(classes="modal-container"):
|
|
606
|
+
yield Label(" Custom Claude Command", classes="modal-title")
|
|
607
|
+
yield Label(f"for {self._name}", classes="label-secondary")
|
|
608
|
+
|
|
609
|
+
yield Label("PRESETS", classes="section-header")
|
|
610
|
+
with Horizontal(classes="action-row"):
|
|
611
|
+
for label, _cmd in self.PRESETS:
|
|
612
|
+
yield Button(label, id=f"btn-preset-{label}", variant="default")
|
|
613
|
+
|
|
614
|
+
yield Label("COMMAND", classes="section-header")
|
|
615
|
+
yield Input(
|
|
616
|
+
value=self._current_command or "",
|
|
617
|
+
id="input-claude-command",
|
|
618
|
+
placeholder="e.g., claude --model opus",
|
|
619
|
+
max_length=MAX_CLAUDE_COMMAND_LENGTH,
|
|
620
|
+
)
|
|
621
|
+
fallback = "repo default" if self._is_worktree else "folder default"
|
|
622
|
+
yield Label(
|
|
623
|
+
f"Leave empty to use {fallback}",
|
|
624
|
+
classes="label-muted",
|
|
625
|
+
)
|
|
626
|
+
yield Label("", id="label-error", classes="label-destructive")
|
|
627
|
+
|
|
628
|
+
with Horizontal(classes="modal-buttons"):
|
|
629
|
+
yield Button("Cancel", id="btn-cancel", variant="default")
|
|
630
|
+
yield Button("Save", id="btn-save", variant="primary")
|
|
631
|
+
|
|
632
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
633
|
+
"""Handle button presses."""
|
|
634
|
+
btn_id = event.button.id or ""
|
|
635
|
+
|
|
636
|
+
if btn_id == "btn-cancel":
|
|
637
|
+
self.dismiss(ClaudeCommandResult(command=None, cancelled=True))
|
|
638
|
+
elif btn_id == "btn-save":
|
|
639
|
+
self._save_command()
|
|
640
|
+
elif btn_id.startswith("btn-preset-"):
|
|
641
|
+
# Handle preset buttons
|
|
642
|
+
preset_label = btn_id.replace("btn-preset-", "")
|
|
643
|
+
for label, cmd in self.PRESETS:
|
|
644
|
+
if label == preset_label:
|
|
645
|
+
self.query_one("#input-claude-command", Input).value = cmd
|
|
646
|
+
break
|
|
647
|
+
|
|
648
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
649
|
+
"""Handle enter key in input."""
|
|
650
|
+
if event.input.id == "input-claude-command":
|
|
651
|
+
self._save_command()
|
|
652
|
+
|
|
653
|
+
def _save_command(self) -> None:
|
|
654
|
+
"""Save the custom command."""
|
|
655
|
+
command = self.query_one("#input-claude-command", Input).value.strip()
|
|
656
|
+
error_label = self.query_one("#label-error", Label)
|
|
657
|
+
|
|
658
|
+
error = validate_claude_command(command)
|
|
659
|
+
if error:
|
|
660
|
+
error_label.update(f" {error}")
|
|
661
|
+
return
|
|
662
|
+
|
|
663
|
+
# Return None command to clear the setting, or the command string
|
|
664
|
+
self.dismiss(ClaudeCommandResult(command=command if command else None))
|
|
665
|
+
|
|
666
|
+
def action_cancel(self) -> None:
|
|
667
|
+
"""Cancel and close the modal."""
|
|
668
|
+
self.dismiss(ClaudeCommandResult(command=None, cancelled=True))
|