octotui 0.1.1__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,1346 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Optional, Dict
4
+ from textual.app import App, ComposeResult
5
+ from textual.widgets import (
6
+ Static,
7
+ Header,
8
+ Footer,
9
+ Button,
10
+ Tree,
11
+ Label,
12
+ Input,
13
+ TabbedContent,
14
+ TabPane,
15
+ Select,
16
+ TextArea,
17
+ )
18
+ from textual.containers import Horizontal, Vertical, Container, VerticalScroll
19
+ from octotui.git_status_sidebar import GitStatusSidebar, Hunk
20
+ from octotui.octotui_logo import OctotuiLogo
21
+ from octotui.gac_integration import GACIntegration
22
+ from octotui.gac_config_modal import GACConfigModal
23
+ from octotui.diff_markdown import DiffMarkdown, DiffMarkdownConfig
24
+ from textual.widget import Widget
25
+ from textual.screen import ModalScreen
26
+ from textual.widgets import OptionList
27
+ from textual.widgets.option_list import Option
28
+ import time
29
+
30
+
31
+ class CommitLine(Static):
32
+ """A widget for displaying a commit line with SHA and message."""
33
+
34
+ DEFAULT_CSS = """
35
+ CommitLine {
36
+ width: 100%;
37
+ height: 1;
38
+ overflow: hidden hidden;
39
+ }
40
+ """
41
+
42
+
43
+ class GitDiffHistoryTabs(Widget):
44
+ """A widget that contains tabbed diff view, commit history, and commit message."""
45
+
46
+ def compose(self) -> ComposeResult:
47
+ """Create the tabbed content with diff view, commit history, and commit message tabs."""
48
+ with TabbedContent():
49
+ with TabPane("Diff View"):
50
+ yield VerticalScroll(id="diff-content")
51
+ with TabPane("Commit History"):
52
+ yield VerticalScroll(id="history-content")
53
+ with TabPane("Commit Message"):
54
+ yield Vertical(
55
+ Label("Commit Message (Subject):", classes="commit-label"),
56
+ Horizontal(
57
+ Input(
58
+ placeholder="Enter commit message...",
59
+ id="commit-message",
60
+ classes="commit-input",
61
+ ),
62
+ Button("GAC", id="gac-button", classes="gac-button"),
63
+ classes="commit-message-row",
64
+ ),
65
+ Label("Commit Details (Body):", classes="commit-label"),
66
+ TextArea(
67
+ placeholder="Enter detailed description (optional)...",
68
+ id="commit-body",
69
+ classes="commit-body",
70
+ ),
71
+ Button("Commit", id="commit-button", classes="commit-button"),
72
+ id="commit-section",
73
+ classes="commit-section",
74
+ )
75
+
76
+
77
+ class GitStatusTabs(Widget):
78
+ """A widget that contains tabbed unstaged and staged changes."""
79
+
80
+ def compose(self) -> ComposeResult:
81
+ """Create the tabbed content with unstaged and staged changes tabs."""
82
+ with TabbedContent(id="status-tabs"):
83
+ with TabPane("Unstaged Changes", id="unstaged-tab"):
84
+ yield VerticalScroll(
85
+ Static(
86
+ "Hint: Select a file and press 's' to stage the entire file",
87
+ classes="hint",
88
+ ),
89
+ Tree("Unstaged", id="unstaged-tree"),
90
+ )
91
+ with TabPane("Staged Changes", id="staged-tab"):
92
+ yield VerticalScroll(
93
+ Tree("Staged", id="staged-tree"),
94
+ )
95
+
96
+
97
+ class HelpModal(ModalScreen):
98
+ """Modal screen for displaying help and keybindings."""
99
+
100
+ DEFAULT_CSS = """
101
+ HelpModal {
102
+ align: center middle;
103
+ }
104
+
105
+ Container {
106
+ border: solid #6c7086;
107
+ background: #00122f;
108
+ width: 80%;
109
+ height: 90%;
110
+ max-width: 120;
111
+ max-height: 50;
112
+ margin: 1;
113
+ padding: 0;
114
+ }
115
+
116
+ VerticalScroll {
117
+ height: 1fr;
118
+ border: none;
119
+ padding: 1 2;
120
+ min-height: 30;
121
+ }
122
+
123
+ .help-title {
124
+ text-align: center;
125
+ text-style: bold;
126
+ color: #bb9af7;
127
+ margin: 0 0 1 0;
128
+ }
129
+
130
+ .help-section {
131
+ margin: 1 0;
132
+ }
133
+
134
+ .help-section-title {
135
+ text-style: bold;
136
+ color: #9ece6a;
137
+ margin: 0 0 1 0;
138
+ }
139
+
140
+ .help-key {
141
+ color: #a9a1e1;
142
+ text-style: bold;
143
+ }
144
+
145
+ .help-desc {
146
+ color: #c0caf5;
147
+ }
148
+ """
149
+
150
+ def compose(self) -> ComposeResult:
151
+ """Create the help modal content."""
152
+ with Container():
153
+ yield Static("🐶 Tentacle - Keybindings", classes="help-title")
154
+ with VerticalScroll():
155
+ yield self._get_help_content()
156
+ with Horizontal():
157
+ yield Button("Close", classes="cancel-button")
158
+
159
+ def _get_help_content(self) -> Static:
160
+ """Generate the help content with all keybindings."""
161
+ help_text = """
162
+ [help-section-title]📁 File Navigation[/help-section-title]
163
+ [help-key]↑/↓[/help-key] Navigate through files and hunks
164
+ [help-key]Enter[/help-key] Select file to view diff
165
+ [help-key]Tab[/help-key] Navigate through UI elements (Shift+Tab to go backwards)
166
+
167
+ [help-section-title]📑 Tab Navigation[/help-section-title]
168
+ [help-key]1 or Ctrl+1[/help-key] Switch to Unstaged Changes tab
169
+ [help-key]2 or Ctrl+2[/help-key] Switch to Staged Changes tab
170
+
171
+ [help-section-title]🔄 Git Operations[/help-section-title]
172
+ [help-key]s[/help-key] Stage selected file (works from any tab)
173
+ [help-key]u[/help-key] Unstage selected file (works from any tab)
174
+ [help-key]a[/help-key] Stage ALL unstaged changes
175
+ [help-key]x[/help-key] Unstage ALL staged changes
176
+ [help-key]c[/help-key] Commit staged changes
177
+
178
+ [help-section-title]🌿 Branch Management[/help-section-title]
179
+ [help-key]b[/help-key] Show branch switcher
180
+ [help-key]r[/help-key] Refresh branches
181
+
182
+ [help-section-title]📡 Remote Operations[/help-section-title]
183
+ [help-key]p[/help-key] Push current branch
184
+ [help-key]o[/help-key] Pull latest changes
185
+
186
+ [help-section-title]🤖 AI Integration (GAC)[/help-section-title]
187
+ [help-key]Ctrl+G[/help-key] Configure GAC (21+ providers supported)
188
+ [help-key]g[/help-key] Generate commit message with AI
189
+
190
+ GAC supports OpenAI, Anthropic, Gemini, Mistral, Cohere, DeepSeek,
191
+ Groq, Together, Cerebras, OpenRouter, xAI, Ollama, and more!
192
+
193
+ [help-section-title]⚙️ Application[/help-section-title]
194
+ [help-key]h[/help-key] Show this help modal
195
+ [help-key]r[/help-key] Refresh git status and file tree
196
+ [help-key]q[/help-key] Quit application
197
+
198
+ [help-section-title]💡 UI Layout[/help-section-title]
199
+ The right panel uses a tabbed layout for Unstaged and Staged changes.
200
+ Use the shortcuts above to quickly switch between tabs, or click them.
201
+ Staging/unstaging operations work from either tab.
202
+ """
203
+ return Static(help_text)
204
+
205
+ def on_button_pressed(self, event: Button.Pressed) -> None:
206
+ """Handle button press events."""
207
+ # Check if this is the close button (any button in this modal is close)
208
+ self.dismiss()
209
+
210
+ def key(self, event) -> bool:
211
+ """Handle key events in the modal."""
212
+ if event.name == "escape":
213
+ self.dismiss()
214
+ return True
215
+ return super().key(event)
216
+
217
+
218
+ class BranchSwitchModal(ModalScreen):
219
+ """Modal screen for switching branches."""
220
+
221
+ DEFAULT_CSS = """
222
+ BranchSwitchModal {
223
+ align: center middle;
224
+ }
225
+
226
+ #Container {
227
+ border: solid #6c7086;
228
+ background: #00122f;
229
+ width: 50%;
230
+ height: 50%;
231
+ margin: 1;
232
+ padding: 1;
233
+ }
234
+
235
+ OptionList {
236
+ height: 1fr;
237
+ border: solid #6c7086;
238
+ }
239
+ """
240
+
241
+ def __init__(self, git_sidebar: GitStatusSidebar):
242
+ super().__init__()
243
+ self.git_sidebar = git_sidebar
244
+
245
+ def compose(self) -> ComposeResult:
246
+ """Create the modal content."""
247
+ with Container():
248
+ yield Static("Switch Branch", classes="panel-header")
249
+ yield OptionList()
250
+ with Horizontal():
251
+ yield Button(
252
+ "Cancel", id="cancel-branch-switch", classes="cancel-button"
253
+ )
254
+ yield Button("Refresh", id="refresh-branches", classes="refresh-button")
255
+
256
+ def on_mount(self) -> None:
257
+ """Populate the branch list when the modal is mounted."""
258
+ self.populate_branch_list()
259
+
260
+ def populate_branch_list(self) -> None:
261
+ """Populate the option list with all available branches."""
262
+ try:
263
+ option_list = self.query_one(OptionList)
264
+ option_list.clear_options()
265
+
266
+ # Get all branches
267
+ branches = self.git_sidebar.get_all_branches()
268
+ current_branch = self.git_sidebar.get_current_branch()
269
+
270
+ # Add branches to the option list
271
+ for branch in branches:
272
+ if branch == current_branch:
273
+ option_list.add_option(Option(branch, id=branch, disabled=True))
274
+ else:
275
+ option_list.add_option(Option(branch, id=branch))
276
+
277
+ except Exception as e:
278
+ self.app.notify(f"Error populating branches: {e}", severity="error")
279
+
280
+ def on_button_pressed(self, event: Button.Pressed) -> None:
281
+ """Handle button presses in the modal."""
282
+ if event.button.id == "cancel-branch-switch":
283
+ self.app.pop_screen()
284
+ elif event.button.id == "refresh-branches":
285
+ self.populate_branch_list()
286
+ self.app.notify("Branch list refreshed", severity="information")
287
+
288
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
289
+ """Handle branch selection."""
290
+ branch_name = event.option.id
291
+
292
+ if branch_name:
293
+ # Check if repo is dirty before switching
294
+ if self.git_sidebar.is_dirty():
295
+ self.app.notify(
296
+ "Cannot switch branches with uncommitted changes. Please commit or discard changes first.",
297
+ severity="error",
298
+ )
299
+ else:
300
+ # Attempt to switch branch
301
+ success = self.git_sidebar.switch_branch(branch_name)
302
+ if success:
303
+ self.app.notify(
304
+ f"Switched to branch: {branch_name}", severity="information"
305
+ )
306
+ # Refresh the UI
307
+ self.app.populate_file_tree()
308
+ self.app.populate_commit_history()
309
+ # Close the modal
310
+ self.app.pop_screen()
311
+ else:
312
+ self.app.notify(
313
+ f"Failed to switch to branch: {branch_name}", severity="error"
314
+ )
315
+
316
+
317
+ class GitDiffViewer(App):
318
+ """A Textual app for viewing git diffs with hunk-based staging in a three-panel UI."""
319
+
320
+ TITLE = "Tentacle"
321
+ CSS_PATH = "style.tcss"
322
+ THEME = "tokyo-night"
323
+ BINDINGS = [
324
+ ("q", "quit", "Quit"),
325
+ ("c", "commit", "Commit Staged Changes"),
326
+ ("g", "gac_generate", "GAC Generate Message"),
327
+ ("Ctrl+g", "gac_config", "Configure GAC"),
328
+ ("h", "show_help", "Show Help"),
329
+ ("a", "stage_all", "Stage All Changes"),
330
+ ("x", "unstage_all", "Unstage All Changes"),
331
+ ("r", "refresh_branches", "Refresh"),
332
+ ("b", "show_branch_switcher", "Switch Branch"),
333
+ ("s", "stage_selected_file", "Stage Selected File"),
334
+ ("u", "unstage_selected_file", "Unstage Selected File"),
335
+ ("p", "push_changes", "Push"),
336
+ ("o", "pull_changes", "Pull"),
337
+ ("1", "switch_to_unstaged", "Switch to Unstaged Tab"),
338
+ ("2", "switch_to_staged", "Switch to Staged Tab"),
339
+ ("ctrl+1", "switch_to_unstaged", "Switch to Unstaged Tab"),
340
+ ("ctrl+2", "switch_to_staged", "Switch to Staged Tab"),
341
+ ]
342
+
343
+ def __init__(self, repo_path: str = None):
344
+ super().__init__()
345
+ self.dark = True
346
+ self.gac_integration = None
347
+ self.git_sidebar = GitStatusSidebar(repo_path)
348
+ self.gac_integration = GACIntegration(self.git_sidebar)
349
+ self.current_file = None
350
+ self.current_commit = None
351
+ self.file_tree = None
352
+ self.current_is_staged = None
353
+ self._current_displayed_file = None
354
+ self._current_displayed_is_staged = None
355
+
356
+ def compose(self) -> ComposeResult:
357
+ """Create the UI layout with three-panel view: file tree, diff view, and commit history."""
358
+ yield Header()
359
+
360
+ yield Horizontal(
361
+ # Left panel - File tree
362
+ Vertical(
363
+ OctotuiLogo(),
364
+ Static("File Tree", id="sidebar-header", classes="panel-header"),
365
+ Tree(os.path.basename(os.getcwd()), id="file-tree"),
366
+ id="sidebar",
367
+ ),
368
+ # Center panel - Tabbed diff view and commit history
369
+ Vertical(GitDiffHistoryTabs(), id="diff-panel"),
370
+ # Right panel - Git status functionality
371
+ Vertical(
372
+ # Tabbed content for Unstaged/Staged changes
373
+ GitStatusTabs(),
374
+ id="status-panel",
375
+ ),
376
+ id="main-content",
377
+ )
378
+ yield Footer()
379
+
380
+ def on_mount(self) -> None:
381
+ """Initialize the UI when app mounts."""
382
+ self.populate_file_tree()
383
+ self.populate_unstaged_changes()
384
+ self.populate_staged_changes()
385
+ self.populate_commit_history()
386
+
387
+ # If no files are selected, show a message in the diff panel
388
+ try:
389
+ diff_content = self.query_one("#diff-content", VerticalScroll)
390
+ if not diff_content.children:
391
+ diff_content.mount(
392
+ Static(
393
+ "Select a file from the tree to view its diff", classes="info"
394
+ )
395
+ )
396
+ except Exception:
397
+ pass
398
+ try:
399
+ history_content = self.query_one("#history-content", VerticalScroll)
400
+ if not history_content.children:
401
+ history_content.mount(
402
+ Static("No commit history available", classes="info")
403
+ )
404
+ except Exception:
405
+ pass
406
+
407
+ def populate_branch_dropdown(self) -> None:
408
+ """Populate the branch dropdown with all available branches."""
409
+ try:
410
+ # Get the select widget
411
+ branch_select = self.query_one("#branch-select", Select)
412
+
413
+ # Get all branches
414
+ branches = self.git_sidebar.get_all_branches()
415
+ current_branch = self.git_sidebar.get_current_branch()
416
+
417
+ # Create options for the select widget
418
+ options = [(branch, branch) for branch in branches]
419
+
420
+ # Set the options and default value
421
+ branch_select.set_options(options)
422
+ branch_select.value = current_branch
423
+
424
+ except Exception:
425
+ # If we can't populate branches, that's okay - continue without it
426
+ pass
427
+
428
+ def action_show_branch_switcher(self) -> None:
429
+ """Show the branch switcher modal."""
430
+ modal = BranchSwitchModal(self.git_sidebar)
431
+ self.push_screen(modal)
432
+
433
+ def action_refresh_branches(self) -> None:
434
+ """Refresh all git status components including file trees and commit history."""
435
+ # Get fresh data from git
436
+ file_data = self.git_sidebar.collect_file_data()
437
+
438
+ # Refresh all components
439
+ self.populate_file_tree()
440
+ self.populate_unstaged_changes(file_data)
441
+ self.populate_staged_changes(file_data)
442
+ self.populate_branch_dropdown()
443
+ self.populate_commit_history()
444
+
445
+ # Also refresh the diff view if a file is currently selected
446
+ if self.current_file:
447
+ self.display_file_diff(
448
+ self.current_file, self.current_is_staged, force_refresh=True
449
+ )
450
+
451
+ def action_quit(self) -> None:
452
+ """Quit the application with a message."""
453
+ self.exit("Thanks for using GitDiffViewer!")
454
+
455
+ def on_unstaged_tree_node_selected(self, event: Tree.NodeSelected) -> None:
456
+ """Handle unstaged tree node selection to display file diffs."""
457
+ node_data = event.node.data
458
+
459
+ if node_data and isinstance(node_data, dict) and "path" in node_data:
460
+ file_path = node_data["path"]
461
+ self.current_file = file_path
462
+ self.display_file_diff(file_path, is_staged=False)
463
+
464
+ def on_staged_tree_node_selected(self, event: Tree.NodeSelected) -> None:
465
+ """Handle staged tree node selection to display file diffs."""
466
+ node_data = event.node.data
467
+
468
+ if node_data and isinstance(node_data, dict) and "path" in node_data:
469
+ file_path = node_data["path"]
470
+ self.current_file = file_path
471
+ self.display_file_diff(file_path, is_staged=True)
472
+
473
+ def on_unstaged_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None:
474
+ """Handle unstaged tree node highlighting to display file diffs."""
475
+ node_data = event.node.data
476
+
477
+ if node_data and isinstance(node_data, dict) and "path" in node_data:
478
+ file_path = node_data["path"]
479
+ self.current_file = file_path
480
+ self.display_file_diff(file_path, is_staged=False)
481
+
482
+ def on_staged_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None:
483
+ """Handle staged tree node highlighting to display file diffs."""
484
+ node_data = event.node.data
485
+
486
+ if node_data and isinstance(node_data, dict) and "path" in node_data:
487
+ file_path = node_data["path"]
488
+ self.current_file = file_path
489
+ self.display_file_diff(file_path, is_staged=True)
490
+
491
+ def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
492
+ """Handle tree node selection to display file diffs."""
493
+ node_data = event.node.data
494
+
495
+ if node_data and isinstance(node_data, dict) and "path" in node_data:
496
+ file_path = node_data["path"]
497
+ status = node_data.get("status", "unchanged")
498
+ is_staged = status == "staged"
499
+ self.current_file = file_path
500
+ self.display_file_diff(file_path, is_staged)
501
+
502
+ def on_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None:
503
+ """Handle tree node highlighting to display file diffs."""
504
+ node_data = event.node.data
505
+
506
+ if node_data and isinstance(node_data, dict) and "path" in node_data:
507
+ file_path = node_data["path"]
508
+ status = node_data.get("status", "unchanged")
509
+ is_staged = status == "staged"
510
+ self.current_file = file_path
511
+ self.display_file_diff(file_path, is_staged)
512
+
513
+ def _reverse_sanitize_path(self, sanitized_path: str) -> str:
514
+ """Reverse the sanitization of a file path.
515
+
516
+ Args:
517
+ sanitized_path: The sanitized path with encoded characters
518
+
519
+ Returns:
520
+ The original file path
521
+ """
522
+ return (
523
+ sanitized_path.replace("__SLASH__", "/")
524
+ .replace("__SPACE__", " ")
525
+ .replace("__DOT__", ".")
526
+ )
527
+
528
+ @staticmethod
529
+ def _hunk_has_changes(hunk: Hunk) -> bool:
530
+ """Return True when a hunk contains any staged or unstaged edits."""
531
+ return any(
532
+ (line and line[:1] in {"+", "-"}) for line in getattr(hunk, "lines", [])
533
+ )
534
+
535
+ def on_button_pressed(self, event: Button.Pressed) -> None:
536
+ """Handle button press events for hunk operations and commit."""
537
+ button_id = event.button.id
538
+
539
+ if button_id and button_id.startswith("stage-hunk-"):
540
+ # Extract hunk index and file path (ignoring the timestamp at the end)
541
+ parts = button_id.split("-")
542
+ if len(parts) >= 4:
543
+ hunk_index = int(parts[2])
544
+ # Join parts 3 through second-to-last (excluding timestamp)
545
+ sanitized_file_path = "-".join(parts[3:-1])
546
+ file_path = self._reverse_sanitize_path(sanitized_file_path)
547
+ self.stage_hunk(file_path, hunk_index)
548
+
549
+ elif button_id and button_id.startswith("unstage-hunk-"):
550
+ # Extract hunk index and file path (ignoring the timestamp at the end)
551
+ parts = button_id.split("-")
552
+ if len(parts) >= 4:
553
+ hunk_index = int(parts[2])
554
+ # Join parts 3 through second-to-last (excluding timestamp)
555
+ sanitized_file_path = "-".join(parts[3:-1])
556
+ file_path = self._reverse_sanitize_path(sanitized_file_path)
557
+ self.unstage_hunk(file_path, hunk_index)
558
+
559
+ elif button_id and button_id.startswith("discard-hunk-"):
560
+ # Extract hunk index and file path (ignoring the timestamp at the end)
561
+ parts = button_id.split("-")
562
+ if len(parts) >= 4:
563
+ hunk_index = int(parts[2])
564
+ # Join parts 3 through second-to-last (excluding timestamp)
565
+ sanitized_file_path = "-".join(parts[3:-1])
566
+ file_path = self._reverse_sanitize_path(sanitized_file_path)
567
+ self.discard_hunk(file_path, hunk_index)
568
+
569
+ elif button_id == "commit-button":
570
+ self.action_commit()
571
+
572
+ elif button_id == "gac-button":
573
+ self.action_gac_generate()
574
+
575
+ def on_select_changed(self, event: Select.Changed) -> None:
576
+ """Handle branch selection changes."""
577
+ if event.select.id == "branch-select":
578
+ branch_name = event.value
579
+ if branch_name:
580
+ # Check if repo is dirty before switching
581
+ if self.git_sidebar.is_dirty():
582
+ self.notify(
583
+ "Cannot switch branches with uncommitted changes. Please commit or discard changes first.",
584
+ severity="error",
585
+ )
586
+ # Reset to current branch
587
+ current_branch = self.git_sidebar.get_current_branch()
588
+ event.select.value = current_branch
589
+ else:
590
+ # Attempt to switch branch
591
+ success = self.git_sidebar.switch_branch(branch_name)
592
+ if success:
593
+ self.notify(
594
+ f"Switched to branch: {branch_name}", severity="information"
595
+ )
596
+ # Refresh the UI
597
+ self.populate_branch_dropdown()
598
+ self.populate_file_tree()
599
+ self.populate_commit_history()
600
+ else:
601
+ self.notify(
602
+ f"Failed to switch to branch: {branch_name}",
603
+ severity="error",
604
+ )
605
+ # Reset to current branch
606
+ current_branch = self.git_sidebar.get_current_branch()
607
+ event.select.value = current_branch
608
+
609
+
610
+ """Populate the file tree sidebar with all files and their git status."""
611
+ if not self.git_sidebar.repo:
612
+ return
613
+
614
+ try:
615
+ # Get the tree widget
616
+ tree = self.query_one("#file-tree", Tree)
617
+
618
+ # Clear existing tree
619
+ tree.clear()
620
+
621
+ # Automatically expand the root node
622
+ tree.root.expand()
623
+
624
+ # Get all files in the repository with their statuses
625
+ file_tree = self.git_sidebar.get_file_tree()
626
+
627
+ # Sort file_tree so directories are processed first
628
+ file_tree.sort(key=lambda x: (x[1] != "directory", x[0]))
629
+
630
+ # Keep track of created directory nodes to avoid duplicates
631
+ directory_nodes = {"": tree.root} # Empty string maps to root node
632
+
633
+ # Add all files and directories
634
+ for file_path, file_type, git_status in file_tree:
635
+ parts = file_path.split("/")
636
+
637
+ for i in range(len(parts)):
638
+ # For directories, we need to process all parts
639
+ # For files, we need to process all parts except the last one (handled separately)
640
+ if file_type == "directory" or i < len(parts) - 1:
641
+ parent_path = "/".join(parts[:i])
642
+ current_path = "/".join(parts[: i + 1])
643
+
644
+ # Create node if it doesn't exist
645
+ if current_path not in directory_nodes:
646
+ parent_node = directory_nodes[parent_path]
647
+ new_node = parent_node.add(parts[i], expand=True)
648
+ new_node.label.stylize(
649
+ "bold #bb9af7"
650
+ ) # Color directories with accent color
651
+ directory_nodes[current_path] = new_node
652
+
653
+ # For files, add as leaf node under the appropriate directory
654
+ if file_type == "file":
655
+ # Get the parent directory node
656
+ parent_dir_path = "/".join(parts[:-1])
657
+ parent_node = (
658
+ directory_nodes[parent_dir_path]
659
+ if parent_dir_path
660
+ else tree.root
661
+ )
662
+
663
+ leaf_node = parent_node.add_leaf(
664
+ parts[-1], data={"path": file_path, "status": git_status}
665
+ )
666
+ # Apply specific text colors based on git status
667
+ if git_status == "staged":
668
+ leaf_node.label.stylize("bold #9ece6a")
669
+ elif git_status == "modified":
670
+ leaf_node.label.stylize("bold #a9a1e1")
671
+ elif git_status == "untracked":
672
+ leaf_node.label.stylize("bold purple")
673
+ else: # unchanged
674
+ leaf_node.label.stylize("default")
675
+
676
+ except Exception as e:
677
+ # Show error in diff panel
678
+ try:
679
+ diff_content = self.query_one("#diff-content", VerticalScroll)
680
+ diff_content.remove_children()
681
+ diff_content.mount(
682
+ Static(f"Error populating file tree: {e}", classes="error")
683
+ )
684
+ except Exception:
685
+ # If we can't even show the error, that's okay - just continue without it
686
+ pass
687
+
688
+ def populate_unstaged_changes(self, file_data: Optional[Dict] = None) -> None:
689
+ """Populate the unstaged changes tree in the right sidebar."""
690
+ if not self.git_sidebar.repo:
691
+ return
692
+
693
+ file_data = file_data or self.git_sidebar.collect_file_data()
694
+ try:
695
+ # Get the unstaged tree widget
696
+ tree = self.query_one("#unstaged-tree", Tree)
697
+
698
+ # Clear existing tree
699
+ tree.clear()
700
+
701
+ # Automatically expand the root node
702
+ tree.root.expand()
703
+
704
+ # Use pre-fetched unstaged files
705
+ unstaged_files = file_data["unstaged_files"]
706
+ untracked_files = set(file_data["untracked_files"])
707
+
708
+ # Sort unstaged_files so directories are processed first
709
+ unstaged_files.sort()
710
+
711
+ # Keep track of created directory nodes to avoid duplicates
712
+ directory_nodes = {"": tree.root} # Empty string maps to root node
713
+
714
+ # Add unstaged files to tree with directory structure
715
+ for file_path in unstaged_files:
716
+ parts = file_path.split("/")
717
+ file_name = parts[-1]
718
+
719
+ # Determine file status from pre-fetched data
720
+ status = "untracked" if file_path in untracked_files else "modified"
721
+
722
+ # Build intermediate directory nodes as needed
723
+ for i in range(len(parts) - 1):
724
+ parent_path = "/".join(parts[:i])
725
+ current_path = "/".join(parts[: i + 1])
726
+
727
+ # Create node if it doesn't exist
728
+ if current_path not in directory_nodes:
729
+ parent_node = directory_nodes[parent_path]
730
+ new_node = parent_node.add(parts[i], expand=True)
731
+ new_node.label.stylize(
732
+ "bold #bb9af7"
733
+ ) # Color directories with accent color
734
+ directory_nodes[current_path] = new_node
735
+
736
+ # Add file as leaf node under the appropriate directory
737
+ parent_dir_path = "/".join(parts[:-1])
738
+ parent_node = (
739
+ directory_nodes[parent_dir_path] if parent_dir_path else tree.root
740
+ )
741
+
742
+ leaf_node = parent_node.add_leaf(
743
+ file_name, data={"path": file_path, "status": status}
744
+ )
745
+
746
+ # Apply styling based on status
747
+ if status == "modified":
748
+ leaf_node.label.stylize("bold #a9a1e1")
749
+ else: # untracked
750
+ leaf_node.label.stylize("bold purple")
751
+
752
+ except Exception as e:
753
+ # Show error in diff panel
754
+ try:
755
+ diff_content = self.query_one("#diff-content", VerticalScroll)
756
+ diff_content.remove_children()
757
+ diff_content.mount(
758
+ Static(f"Error populating unstaged changes: {e}", classes="error")
759
+ )
760
+ except Exception:
761
+ pass
762
+
763
+ def populate_staged_changes(self, file_data: Optional[Dict] = None) -> None:
764
+ """Populate the staged changes tree in the right sidebar."""
765
+ if not self.git_sidebar.repo:
766
+ return
767
+
768
+ file_data = file_data or self.git_sidebar.collect_file_data()
769
+ try:
770
+ # Get the staged tree widget
771
+ tree = self.query_one("#staged-tree", Tree)
772
+
773
+ # Clear existing tree
774
+ tree.clear()
775
+
776
+ # Automatically expand the root node
777
+ tree.root.expand()
778
+
779
+ # Use pre-fetched staged files
780
+ staged_files = file_data["staged_files"]
781
+
782
+ # Sort staged_files so directories are processed first
783
+ staged_files.sort()
784
+
785
+ # Keep track of created directory nodes to avoid duplicates
786
+ directory_nodes = {"": tree.root} # Empty string maps to root node
787
+
788
+ # Add staged files with directory structure
789
+ for file_path in staged_files:
790
+ parts = file_path.split("/")
791
+ file_name = parts[-1]
792
+
793
+ # Build intermediate directory nodes as needed
794
+ for i in range(len(parts) - 1):
795
+ parent_path = "/".join(parts[:i])
796
+ current_path = "/".join(parts[: i + 1])
797
+
798
+ # Create node if it doesn't exist
799
+ if current_path not in directory_nodes:
800
+ parent_node = directory_nodes[parent_path]
801
+ new_node = parent_node.add(parts[i], expand=True)
802
+ new_node.label.stylize(
803
+ "bold #bb9af7"
804
+ ) # Color directories with accent color
805
+ directory_nodes[current_path] = new_node
806
+
807
+ # Add file as leaf node under the appropriate directory
808
+ parent_dir_path = "/".join(parts[:-1])
809
+ parent_node = (
810
+ directory_nodes[parent_dir_path] if parent_dir_path else tree.root
811
+ )
812
+
813
+ leaf_node = parent_node.add_leaf(
814
+ file_name, data={"path": file_path, "status": "staged"}
815
+ )
816
+ leaf_node.label.stylize("bold #9ece6a")
817
+
818
+ except Exception as e:
819
+ # Show error in diff panel
820
+ try:
821
+ diff_content = self.query_one("#diff-content", VerticalScroll)
822
+ diff_content.remove_children()
823
+ diff_content.mount(
824
+ Static(f"Error populating staged changes: {e}", classes="error")
825
+ )
826
+ except Exception:
827
+ pass
828
+
829
+ def stage_hunk(self, file_path: str, hunk_index: int) -> None:
830
+ """Stage a specific hunk of a file."""
831
+ try:
832
+ success = self.git_sidebar.stage_hunk(file_path, hunk_index)
833
+
834
+ if success:
835
+ # Clear any cached diff state
836
+ if hasattr(self, "_current_displayed_file"):
837
+ delattr(self, "_current_displayed_file")
838
+ if hasattr(self, "_current_displayed_is_staged"):
839
+ delattr(self, "_current_displayed_is_staged")
840
+
841
+ # Refresh tree states with latest git data
842
+ file_data = self.git_sidebar.collect_file_data()
843
+ self.populate_unstaged_changes(file_data)
844
+ self.populate_staged_changes(file_data)
845
+
846
+ # Refresh only the diff view for the current file
847
+ if self.current_file:
848
+ self.display_file_diff(
849
+ self.current_file, self.current_is_staged, force_refresh=True
850
+ )
851
+
852
+ # Schedule a background refresh of file trees (non-blocking)
853
+ self.call_later(self._refresh_trees_async)
854
+ else:
855
+ self.notify(f"Failed to stage {file_path}", severity="error")
856
+
857
+ except Exception as e:
858
+ self.notify(f"Error staging hunk: {e}", severity="error")
859
+
860
+ def stage_file(self, file_path: str) -> None:
861
+ """Stage all changes in a file."""
862
+ try:
863
+ success = self.git_sidebar.stage_file(file_path)
864
+ if success:
865
+ # Refresh trees
866
+ # Refresh diff view for the staged file
867
+ self.display_file_diff(file_path, is_staged=True, force_refresh=True)
868
+ else:
869
+ self.notify(
870
+ f"Failed to stage all changes in {file_path}", severity="error"
871
+ )
872
+ except Exception as e:
873
+ self.notify(f"Error staging file: {e}", severity="error")
874
+
875
+ def unstage_hunk(self, file_path: str, hunk_index: int) -> None:
876
+ """Unstage a specific hunk of a file."""
877
+ try:
878
+ success = self.git_sidebar.unstage_hunk(file_path, hunk_index)
879
+
880
+ if success:
881
+ # Refresh tree states with latest git data
882
+ file_data = self.git_sidebar.collect_file_data()
883
+ self.populate_unstaged_changes(file_data)
884
+ self.populate_staged_changes(file_data)
885
+
886
+ # Refresh only the diff view for the current file
887
+ if self.current_file:
888
+ self.display_file_diff(
889
+ self.current_file, self.current_is_staged, force_refresh=True
890
+ )
891
+
892
+ # Schedule a background refresh of file trees (non-blocking)
893
+ self.call_later(self._refresh_trees_async)
894
+ else:
895
+ self.notify(f"Failed to unstage {file_path}", severity="error")
896
+
897
+ except Exception as e:
898
+ self.notify(f"Error unstaging hunk: {e}", severity="error")
899
+
900
+ def discard_hunk(self, file_path: str, hunk_index: int) -> None:
901
+ """Discard changes in a specific hunk of a file."""
902
+ try:
903
+ success = self.git_sidebar.discard_hunk(file_path, hunk_index)
904
+
905
+ if success:
906
+ # Clear any cached diff state
907
+ if hasattr(self, "_current_displayed_file"):
908
+ delattr(self, "_current_displayed_file")
909
+ if hasattr(self, "_current_displayed_is_staged"):
910
+ delattr(self, "_current_displayed_is_staged")
911
+
912
+ # Refresh tree states with latest git data
913
+ file_data = self.git_sidebar.collect_file_data()
914
+ self.populate_unstaged_changes(file_data)
915
+ self.populate_staged_changes(file_data)
916
+
917
+ # Refresh only the diff view
918
+ if self.current_file:
919
+ self.display_file_diff(
920
+ self.current_file, self.current_is_staged, force_refresh=True
921
+ )
922
+
923
+ # Schedule a background refresh of file trees (non-blocking)
924
+ self.call_later(self._refresh_trees_async)
925
+ else:
926
+ self.notify(
927
+ f"Failed to discard changes in {file_path}", severity="error"
928
+ )
929
+
930
+ except Exception as e:
931
+ self.notify(f"Error discarding hunk: {e}", severity="error")
932
+
933
+ def _refresh_trees_async(self) -> None:
934
+ """Background refresh of file trees to avoid blocking UI during hunk operations."""
935
+ try:
936
+ # Check if we have recently modified files to optimize the refresh
937
+ if self.git_sidebar.has_recent_modifications():
938
+ # For now, still do full refresh but in background
939
+ # Future optimization: only update nodes for modified files
940
+ self.populate_file_tree()
941
+ self.populate_unstaged_changes()
942
+ self.populate_staged_changes()
943
+ else:
944
+ # No recent changes, skip expensive operations
945
+ pass
946
+ except Exception:
947
+ # Silently fail background operations to avoid disrupting user experience
948
+ pass
949
+
950
+ def populate_commit_history(self) -> None:
951
+ """Populate the commit history tab."""
952
+ try:
953
+ history_content = self.query_one("#history-content", VerticalScroll)
954
+ history_content.remove_children()
955
+
956
+ branch_name = self.git_sidebar.get_current_branch()
957
+ commits = self.git_sidebar.get_commit_history()
958
+
959
+ for commit in commits:
960
+ # Display branch, commit ID, author, and message with colors that match our theme
961
+ commit_text = f"[#87CEEB]{branch_name}[/#87CEEB] [#E0FFFF]{commit.sha}[/#E0FFFF] [#00BFFF]{commit.author}[/#00BFFF]: {commit.message}"
962
+ commit_line = CommitLine(commit_text, classes="info")
963
+ history_content.mount(commit_line)
964
+
965
+ except Exception:
966
+ pass
967
+
968
+ def display_file_diff(
969
+ self, file_path: str, is_staged: bool = False, force_refresh: bool = False
970
+ ) -> None:
971
+ """Display the diff for a selected file in the diff panel with appropriate buttons."""
972
+ # Skip if this is the same file we're already displaying (unless force_refresh is True)
973
+ if (
974
+ not force_refresh
975
+ and hasattr(self, "_current_displayed_file")
976
+ and self._current_displayed_file == file_path
977
+ and self._current_displayed_is_staged == is_staged
978
+ ):
979
+ return
980
+ self.current_is_staged = is_staged
981
+
982
+ try:
983
+ diff_content = self.query_one("#diff-content", VerticalScroll)
984
+ # Ensure we're starting with a clean slate
985
+ diff_content.remove_children()
986
+
987
+ # Track which file we're currently displaying
988
+ self._current_displayed_file = file_path
989
+ self._current_displayed_is_staged = is_staged
990
+
991
+ # Get file status to determine which buttons to show
992
+ hunks = self.git_sidebar.get_diff_hunks(file_path, staged=is_staged)
993
+
994
+ if not hunks:
995
+ diff_content.mount(Static("No changes to display", classes="info"))
996
+ return
997
+
998
+ # Generate a unique timestamp for this refresh to avoid ID collisions
999
+ refresh_id = str(int(time.time() * 1000000)) # microsecond timestamp
1000
+
1001
+ repo_root = getattr(self.git_sidebar, "repo_path", Path.cwd())
1002
+ markdown_config = DiffMarkdownConfig(
1003
+ repo_root=repo_root,
1004
+ prefer_diff_language=False,
1005
+ show_headers=False,
1006
+ )
1007
+
1008
+ # Display each hunk
1009
+ for i, hunk in enumerate(hunks):
1010
+ hunk_header = Static(hunk.header, classes="hunk-header")
1011
+
1012
+ markdown_widget = DiffMarkdown(
1013
+ file_path=file_path,
1014
+ hunks=[hunk],
1015
+ config=markdown_config,
1016
+ )
1017
+ markdown_widget.add_class("diff-markdown")
1018
+
1019
+ sanitized_file_path = (
1020
+ file_path.replace("/", "__SLASH__")
1021
+ .replace(" ", "__SPACE__")
1022
+ .replace(".", "__DOT__")
1023
+ )
1024
+ hunk_children = [hunk_header, markdown_widget]
1025
+
1026
+ if self._hunk_has_changes(hunk):
1027
+ if is_staged:
1028
+ hunk_children.append(
1029
+ Horizontal(
1030
+ Button(
1031
+ "Unstage",
1032
+ id=f"unstage-hunk-{i}-{sanitized_file_path}-{refresh_id}",
1033
+ classes="unstage-button",
1034
+ ),
1035
+ classes="hunk-buttons",
1036
+ )
1037
+ )
1038
+ else:
1039
+ hunk_children.append(
1040
+ Horizontal(
1041
+ Button(
1042
+ "Stage",
1043
+ id=f"stage-hunk-{i}-{sanitized_file_path}-{refresh_id}",
1044
+ classes="stage-button",
1045
+ ),
1046
+ Button(
1047
+ "Discard",
1048
+ id=f"discard-hunk-{i}-{sanitized_file_path}-{refresh_id}",
1049
+ classes="discard-button",
1050
+ ),
1051
+ classes="hunk-buttons",
1052
+ )
1053
+ )
1054
+
1055
+ hunk_container = Container(
1056
+ *hunk_children,
1057
+ id=f"{'staged' if is_staged else 'unstaged'}-hunk-{i}-{sanitized_file_path}-{refresh_id}",
1058
+ classes="hunk-container",
1059
+ )
1060
+
1061
+ diff_content.mount(hunk_container)
1062
+
1063
+ except Exception as e:
1064
+ self.notify(f"Error displaying diff: {e}", severity="error")
1065
+
1066
+ def action_commit(self) -> None:
1067
+ """Commit staged changes with a commit message from the UI."""
1068
+ try:
1069
+ # Get the commit message input widgets
1070
+ commit_input = self.query_one("#commit-message", Input)
1071
+ commit_body = self.query_one("#commit-body", TextArea)
1072
+
1073
+ subject = commit_input.value.strip()
1074
+ body = commit_body.text.strip()
1075
+
1076
+ # Combine subject and body for full commit message
1077
+ message = subject
1078
+ if body:
1079
+ message = f"{subject}\n\n{body}"
1080
+
1081
+ # Check if there's a commit message
1082
+ if not subject:
1083
+ self.notify("Please enter a commit message", severity="warning")
1084
+ return
1085
+
1086
+ # Check if there are staged changes
1087
+ staged_files = self.git_sidebar.get_staged_files()
1088
+ if not staged_files:
1089
+ self.notify("No staged changes to commit", severity="warning")
1090
+ return
1091
+
1092
+ # Attempt to commit staged changes
1093
+ success = self.git_sidebar.commit_staged_changes(message)
1094
+
1095
+ if success:
1096
+ self.notify(
1097
+ f"Successfully committed changes with message: {message}",
1098
+ severity="information",
1099
+ )
1100
+ # Clear the commit message inputs
1101
+ commit_input.value = ""
1102
+ commit_body.text = ""
1103
+
1104
+ # Rebuild tree states with latest git data
1105
+ file_data = self.git_sidebar.collect_file_data()
1106
+ self.populate_file_tree()
1107
+ self.populate_unstaged_changes(file_data)
1108
+ self.populate_staged_changes(file_data)
1109
+
1110
+ # Refresh the diff and commit history views
1111
+ if self.current_file:
1112
+ self.display_file_diff(
1113
+ self.current_file, self.current_is_staged, force_refresh=True
1114
+ )
1115
+ self.populate_commit_history()
1116
+ else:
1117
+ self.notify("Failed to commit changes", severity="error")
1118
+
1119
+ except Exception as e:
1120
+ self.notify(f"Error committing changes: {e}", severity="error")
1121
+
1122
+ def action_push_changes(self) -> None:
1123
+ """Push the current branch to its remote."""
1124
+ try:
1125
+ success, message = self.git_sidebar.push_current_branch()
1126
+ if success:
1127
+ self.notify(f"🚀 {message}", severity="information")
1128
+ else:
1129
+ self.notify(message, severity="error")
1130
+ except Exception as e:
1131
+ self.notify(f"Push blew up: {e}", severity="error")
1132
+
1133
+ def action_pull_changes(self) -> None:
1134
+ """Pull the latest changes for the current branch."""
1135
+ try:
1136
+ success, message = self.git_sidebar.pull_current_branch()
1137
+ if success:
1138
+ self.notify(f"📥 {message}", severity="information")
1139
+ # Refresh trees and history to reflect new changes
1140
+ file_data = self.git_sidebar.collect_file_data()
1141
+ self.populate_file_tree()
1142
+ self.populate_unstaged_changes(file_data)
1143
+ self.populate_staged_changes(file_data)
1144
+ self.populate_commit_history()
1145
+ if self.current_file:
1146
+ self.display_file_diff(
1147
+ self.current_file, self.current_is_staged, force_refresh=True
1148
+ )
1149
+ else:
1150
+ self.notify(message, severity="error")
1151
+ except Exception as e:
1152
+ self.notify(f"Pull imploded: {e}", severity="error")
1153
+
1154
+ def action_gac_config(self) -> None:
1155
+ """Show GAC configuration modal."""
1156
+
1157
+ def handle_config_result(result):
1158
+ # Refresh GAC integration after config changes
1159
+ self.gac_integration = GACIntegration(self.git_sidebar)
1160
+
1161
+ self.push_screen(GACConfigModal(), handle_config_result)
1162
+
1163
+ def action_stage_selected_file(self) -> None:
1164
+ """Stage the entire currently selected file from any file tree if it is unstaged/untracked."""
1165
+ try:
1166
+ if not self.current_file:
1167
+ self.notify("No file selected", severity="warning")
1168
+ return
1169
+ status = self.git_sidebar.get_file_status(self.current_file)
1170
+ # Allow staging even if file is partially staged; block only if unchanged
1171
+ if "unchanged" in status:
1172
+ self.notify("Selected file has no changes", severity="information")
1173
+ return
1174
+
1175
+ # Perform the staging operation
1176
+ success = self.git_sidebar.stage_file(self.current_file)
1177
+ if success:
1178
+ # Use the comprehensive refresh function
1179
+ self.action_refresh_branches()
1180
+ # Also refresh diff view for the staged file
1181
+ self.display_file_diff(
1182
+ self.current_file, is_staged=True, force_refresh=True
1183
+ )
1184
+ else:
1185
+ self.notify(
1186
+ f"Failed to stage all changes in {self.current_file}",
1187
+ severity="error",
1188
+ )
1189
+ except Exception as e:
1190
+ self.notify(f"Error staging selected file: {e}", severity="error")
1191
+
1192
+ def action_unstage_selected_file(self) -> None:
1193
+ """Unstage all changes for the selected file (if staged)."""
1194
+ try:
1195
+ if not self.current_file:
1196
+ self.notify("No file selected", severity="warning")
1197
+ return
1198
+ status = self.git_sidebar.get_file_status(self.current_file)
1199
+ if "staged" not in status:
1200
+ self.notify("Selected file is not staged", severity="information")
1201
+ return
1202
+
1203
+ # Perform the unstaging operation
1204
+ if hasattr(self.git_sidebar, "unstage_file_all") and callable(
1205
+ self.git_sidebar.unstage_file_all
1206
+ ):
1207
+ success = self.git_sidebar.unstage_file_all(self.current_file)
1208
+ else:
1209
+ # Fallback: remove entire file from index
1210
+ success = self.git_sidebar.unstage_file(self.current_file)
1211
+
1212
+ if success:
1213
+ # Use the comprehensive refresh function
1214
+ self.action_refresh_branches()
1215
+ # Also refresh diff view to show unstaged changes
1216
+ if self.current_file:
1217
+ self.display_file_diff(
1218
+ self.current_file, is_staged=False, force_refresh=True
1219
+ )
1220
+ else:
1221
+ self.notify(f"Failed to unstage {self.current_file}", severity="error")
1222
+ except Exception as e:
1223
+ self.notify(f"Error unstaging selected file: {e}", severity="error")
1224
+
1225
+ def action_show_help(self) -> None:
1226
+ """Show the help modal with keybindings."""
1227
+ try:
1228
+ help_modal = HelpModal()
1229
+ self.push_screen(help_modal)
1230
+ except Exception as e:
1231
+ self.notify(f"Error showing help: {e}", severity="error")
1232
+
1233
+ def action_stage_all(self) -> None:
1234
+ """Stage all unstaged changes."""
1235
+ try:
1236
+ success, message = self.git_sidebar.stage_all_changes()
1237
+ if success:
1238
+ # Refresh UI
1239
+ self.populate_file_tree()
1240
+ if self.current_file:
1241
+ self.display_file_diff(
1242
+ self.current_file, is_staged=True, force_refresh=True
1243
+ )
1244
+ else:
1245
+ self.notify(message, severity="error")
1246
+ except Exception as e:
1247
+ self.notify(f"Error staging all changes: {e}", severity="error")
1248
+
1249
+ def action_unstage_all(self) -> None:
1250
+ """Unstage all staged changes."""
1251
+ try:
1252
+ success, message = self.git_sidebar.unstage_all_changes()
1253
+ if success:
1254
+ # Refresh UI
1255
+ self.populate_file_tree()
1256
+ if self.current_file:
1257
+ self.display_file_diff(
1258
+ self.current_file, is_staged=False, force_refresh=True
1259
+ )
1260
+ else:
1261
+ self.notify(message, severity="error")
1262
+ except Exception as e:
1263
+ self.notify(f"Error unstaging all changes: {e}", severity="error")
1264
+
1265
+ def action_switch_to_unstaged(self) -> None:
1266
+ """Switch to the Unstaged Changes tab."""
1267
+ try:
1268
+ status_tabs = self.query_one("#status-tabs", TabbedContent)
1269
+ status_tabs.active = "unstaged-tab"
1270
+ except Exception as e:
1271
+ self.notify(f"Error switching to unstaged tab: {e}", severity="error")
1272
+
1273
+ def action_switch_to_staged(self) -> None:
1274
+ """Switch to the Staged Changes tab."""
1275
+ try:
1276
+ status_tabs = self.query_one("#status-tabs", TabbedContent)
1277
+ status_tabs.active = "staged-tab"
1278
+ except Exception as e:
1279
+ self.notify(f"Error switching to staged tab: {e}", severity="error")
1280
+
1281
+ def action_gac_generate(self) -> None:
1282
+ """Generate commit message using GAC and populate the commit message fields (no auto-commit)."""
1283
+ try:
1284
+ if not self.gac_integration.is_configured():
1285
+ self.notify(
1286
+ "🤖 GAC is not configured. Press Ctrl+G to configure it first.",
1287
+ severity="warning",
1288
+ )
1289
+ return
1290
+
1291
+ # Check if there are staged changes
1292
+ staged_files = self.git_sidebar.get_staged_files()
1293
+ if not staged_files:
1294
+ self.notify(
1295
+ "No staged changes to generate commit message for",
1296
+ severity="warning",
1297
+ )
1298
+ return
1299
+
1300
+ # Show generating message
1301
+ self.notify(
1302
+ "🤖 Generating commit message with GAC...", severity="information"
1303
+ )
1304
+
1305
+ # Generate commit message
1306
+ try:
1307
+ commit_message = self.gac_integration.generate_commit_message(
1308
+ staged_only=True, one_liner=False
1309
+ )
1310
+
1311
+ if commit_message:
1312
+ # Parse the commit message into subject and body
1313
+ lines = commit_message.strip().split("\n", 1)
1314
+ subject = lines[0].strip()
1315
+ body = lines[1].strip() if len(lines) > 1 else ""
1316
+
1317
+ # Populate the commit message inputs
1318
+ try:
1319
+ commit_input = self.query_one("#commit-message", Input)
1320
+ commit_body = self.query_one("#commit-body", TextArea)
1321
+
1322
+ commit_input.value = subject
1323
+ commit_body.text = body
1324
+
1325
+ self.notify(
1326
+ f"✅ GAC generated commit message: {subject[:50]}...",
1327
+ severity="information",
1328
+ )
1329
+
1330
+ except Exception as e:
1331
+ self.notify(
1332
+ f"Generated message but failed to populate fields: {e}",
1333
+ severity="warning",
1334
+ )
1335
+ else:
1336
+ self.notify(
1337
+ "❌ GAC failed to generate a commit message", severity="error"
1338
+ )
1339
+
1340
+ except Exception as e:
1341
+ self.notify(
1342
+ f"❌ Failed to generate commit message: {e}", severity="error"
1343
+ )
1344
+
1345
+ except Exception as e:
1346
+ self.notify(f"❌ Error with GAC integration: {e}", severity="error")