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,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))