octotui 0.1.3__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.

Potentially problematic release.


This version of octotui might be problematic. Click here for more details.

@@ -0,0 +1,1416 @@
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 populate_file_tree(self) -> None:
429
+ """Populate the file tree sidebar with all files and their git status."""
430
+ if not self.git_sidebar.repo:
431
+ return
432
+
433
+ try:
434
+ # Get the tree widget
435
+ tree = self.query_one("#file-tree", Tree)
436
+
437
+ # Clear existing tree
438
+ tree.clear()
439
+
440
+ # Automatically expand the root node
441
+ tree.root.expand()
442
+
443
+ # Get all files in the repository with their statuses
444
+ file_data = self.git_sidebar.collect_file_data()
445
+ file_tree = self.git_sidebar.get_file_tree()
446
+
447
+ # Sort file_tree so directories are processed first
448
+ file_tree.sort(key=lambda x: (x[1] != "directory", x[0]))
449
+
450
+ # Keep track of created directory nodes to avoid duplicates
451
+ directory_nodes = {"": tree.root} # Empty string maps to root node
452
+
453
+ # Add all files and directories
454
+ for file_path, file_type, git_status in file_tree:
455
+ parts = file_path.split('/')
456
+
457
+ for i in range(len(parts)):
458
+ # For directories, we need to process all parts
459
+ # For files, we need to process all parts except the last one (handled separately)
460
+ if file_type == "directory" or i < len(parts) - 1:
461
+ parent_path = "/".join(parts[:i])
462
+ current_path = "/".join(parts[:i+1])
463
+
464
+ # Create node if it doesn't exist
465
+ if current_path not in directory_nodes:
466
+ parent_node = directory_nodes[parent_path]
467
+ new_node = parent_node.add(parts[i], expand=True)
468
+ new_node.label.stylize("bold #bb9af7") # Color directories with accent color
469
+ directory_nodes[current_path] = new_node
470
+
471
+ # For files, add as leaf node under the appropriate directory
472
+ if file_type == "file":
473
+ # Get the parent directory node
474
+ parent_dir_path = "/".join(parts[:-1])
475
+ parent_node = directory_nodes[parent_dir_path] if parent_dir_path else tree.root
476
+
477
+ leaf_node = parent_node.add_leaf(parts[-1], data={"path": file_path, "status": git_status})
478
+ # Apply specific text colors based on git status
479
+ if git_status == "staged":
480
+ leaf_node.label.stylize("bold #9ece6a")
481
+ elif git_status == "modified":
482
+ leaf_node.label.stylize("bold #a9a1e1")
483
+ elif git_status == "untracked":
484
+ leaf_node.label.stylize("bold purple")
485
+ else: # unchanged
486
+ leaf_node.label.stylize("default")
487
+
488
+ except Exception as e:
489
+ # Show error in diff panel
490
+ try:
491
+ diff_content = self.query_one("#diff-content", VerticalScroll)
492
+ diff_content.remove_children()
493
+ diff_content.mount(Static(f"Error populating file tree: {e}", classes="error"))
494
+ except Exception:
495
+ # If we can't even show the error, that's okay - just continue without it
496
+ pass
497
+
498
+ def action_show_branch_switcher(self) -> None:
499
+ """Show the branch switcher modal."""
500
+ modal = BranchSwitchModal(self.git_sidebar)
501
+ self.push_screen(modal)
502
+
503
+ def action_refresh_branches(self) -> None:
504
+ """Refresh all git status components including file trees and commit history."""
505
+ # Get fresh data from git
506
+ file_data = self.git_sidebar.collect_file_data()
507
+
508
+ # Refresh all components
509
+ self.populate_file_tree()
510
+ self.populate_unstaged_changes(file_data)
511
+ self.populate_staged_changes(file_data)
512
+ self.populate_branch_dropdown()
513
+ self.populate_commit_history()
514
+
515
+ # Also refresh the diff view if a file is currently selected
516
+ if self.current_file:
517
+ self.display_file_diff(
518
+ self.current_file, self.current_is_staged, force_refresh=True
519
+ )
520
+
521
+ def action_quit(self) -> None:
522
+ """Quit the application with a message."""
523
+ self.exit("Thanks for using GitDiffViewer!")
524
+
525
+ def on_unstaged_tree_node_selected(self, event: Tree.NodeSelected) -> None:
526
+ """Handle unstaged tree node selection to display file diffs."""
527
+ node_data = event.node.data
528
+
529
+ if node_data and isinstance(node_data, dict) and "path" in node_data:
530
+ file_path = node_data["path"]
531
+ self.current_file = file_path
532
+ self.display_file_diff(file_path, is_staged=False)
533
+
534
+ def on_staged_tree_node_selected(self, event: Tree.NodeSelected) -> None:
535
+ """Handle staged tree node selection to display file diffs."""
536
+ node_data = event.node.data
537
+
538
+ if node_data and isinstance(node_data, dict) and "path" in node_data:
539
+ file_path = node_data["path"]
540
+ self.current_file = file_path
541
+ self.display_file_diff(file_path, is_staged=True)
542
+
543
+ def on_unstaged_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None:
544
+ """Handle unstaged tree node highlighting to display file diffs."""
545
+ node_data = event.node.data
546
+
547
+ if node_data and isinstance(node_data, dict) and "path" in node_data:
548
+ file_path = node_data["path"]
549
+ self.current_file = file_path
550
+ self.display_file_diff(file_path, is_staged=False)
551
+
552
+ def on_staged_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None:
553
+ """Handle staged tree node highlighting to display file diffs."""
554
+ node_data = event.node.data
555
+
556
+ if node_data and isinstance(node_data, dict) and "path" in node_data:
557
+ file_path = node_data["path"]
558
+ self.current_file = file_path
559
+ self.display_file_diff(file_path, is_staged=True)
560
+
561
+ def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
562
+ """Handle tree node selection to display file diffs."""
563
+ node_data = event.node.data
564
+
565
+ if node_data and isinstance(node_data, dict) and "path" in node_data:
566
+ file_path = node_data["path"]
567
+ status = node_data.get("status", "unchanged")
568
+ is_staged = status == "staged"
569
+ self.current_file = file_path
570
+ self.display_file_diff(file_path, is_staged)
571
+
572
+ def on_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None:
573
+ """Handle tree node highlighting to display file diffs."""
574
+ node_data = event.node.data
575
+
576
+ if node_data and isinstance(node_data, dict) and "path" in node_data:
577
+ file_path = node_data["path"]
578
+ status = node_data.get("status", "unchanged")
579
+ is_staged = status == "staged"
580
+ self.current_file = file_path
581
+ self.display_file_diff(file_path, is_staged)
582
+
583
+ def _reverse_sanitize_path(self, sanitized_path: str) -> str:
584
+ """Reverse the sanitization of a file path.
585
+
586
+ Args:
587
+ sanitized_path: The sanitized path with encoded characters
588
+
589
+ Returns:
590
+ The original file path
591
+ """
592
+ return (
593
+ sanitized_path.replace("__SLASH__", "/")
594
+ .replace("__SPACE__", " ")
595
+ .replace("__DOT__", ".")
596
+ )
597
+
598
+ @staticmethod
599
+ def _hunk_has_changes(hunk: Hunk) -> bool:
600
+ """Return True when a hunk contains any staged or unstaged edits."""
601
+ return any(
602
+ (line and line[:1] in {"+", "-"}) for line in getattr(hunk, "lines", [])
603
+ )
604
+
605
+ def on_button_pressed(self, event: Button.Pressed) -> None:
606
+ """Handle button press events for hunk operations and commit."""
607
+ button_id = event.button.id
608
+
609
+ if button_id and button_id.startswith("stage-hunk-"):
610
+ # Extract hunk index and file path (ignoring the timestamp at the end)
611
+ parts = button_id.split("-")
612
+ if len(parts) >= 4:
613
+ hunk_index = int(parts[2])
614
+ # Join parts 3 through second-to-last (excluding timestamp)
615
+ sanitized_file_path = "-".join(parts[3:-1])
616
+ file_path = self._reverse_sanitize_path(sanitized_file_path)
617
+ self.stage_hunk(file_path, hunk_index)
618
+
619
+ elif button_id and button_id.startswith("unstage-hunk-"):
620
+ # Extract hunk index and file path (ignoring the timestamp at the end)
621
+ parts = button_id.split("-")
622
+ if len(parts) >= 4:
623
+ hunk_index = int(parts[2])
624
+ # Join parts 3 through second-to-last (excluding timestamp)
625
+ sanitized_file_path = "-".join(parts[3:-1])
626
+ file_path = self._reverse_sanitize_path(sanitized_file_path)
627
+ self.unstage_hunk(file_path, hunk_index)
628
+
629
+ elif button_id and button_id.startswith("discard-hunk-"):
630
+ # Extract hunk index and file path (ignoring the timestamp at the end)
631
+ parts = button_id.split("-")
632
+ if len(parts) >= 4:
633
+ hunk_index = int(parts[2])
634
+ # Join parts 3 through second-to-last (excluding timestamp)
635
+ sanitized_file_path = "-".join(parts[3:-1])
636
+ file_path = self._reverse_sanitize_path(sanitized_file_path)
637
+ self.discard_hunk(file_path, hunk_index)
638
+
639
+ elif button_id == "commit-button":
640
+ self.action_commit()
641
+
642
+ elif button_id == "gac-button":
643
+ self.action_gac_generate()
644
+
645
+ def on_select_changed(self, event: Select.Changed) -> None:
646
+ """Handle branch selection changes."""
647
+ if event.select.id == "branch-select":
648
+ branch_name = event.value
649
+ if branch_name:
650
+ # Check if repo is dirty before switching
651
+ if self.git_sidebar.is_dirty():
652
+ self.notify(
653
+ "Cannot switch branches with uncommitted changes. Please commit or discard changes first.",
654
+ severity="error",
655
+ )
656
+ # Reset to current branch
657
+ current_branch = self.git_sidebar.get_current_branch()
658
+ event.select.value = current_branch
659
+ else:
660
+ # Attempt to switch branch
661
+ success = self.git_sidebar.switch_branch(branch_name)
662
+ if success:
663
+ self.notify(
664
+ f"Switched to branch: {branch_name}", severity="information"
665
+ )
666
+ # Refresh the UI
667
+ self.populate_branch_dropdown()
668
+ self.populate_file_tree()
669
+ self.populate_commit_history()
670
+ else:
671
+ self.notify(
672
+ f"Failed to switch to branch: {branch_name}",
673
+ severity="error",
674
+ )
675
+ # Reset to current branch
676
+ current_branch = self.git_sidebar.get_current_branch()
677
+ event.select.value = current_branch
678
+
679
+
680
+ """Populate the file tree sidebar with all files and their git status."""
681
+ if not self.git_sidebar.repo:
682
+ return
683
+
684
+ try:
685
+ # Get the tree widget
686
+ tree = self.query_one("#file-tree", Tree)
687
+
688
+ # Clear existing tree
689
+ tree.clear()
690
+
691
+ # Automatically expand the root node
692
+ tree.root.expand()
693
+
694
+ # Get all files in the repository with their statuses
695
+ file_tree = self.git_sidebar.get_file_tree()
696
+
697
+ # Sort file_tree so directories are processed first
698
+ file_tree.sort(key=lambda x: (x[1] != "directory", x[0]))
699
+
700
+ # Keep track of created directory nodes to avoid duplicates
701
+ directory_nodes = {"": tree.root} # Empty string maps to root node
702
+
703
+ # Add all files and directories
704
+ for file_path, file_type, git_status in file_tree:
705
+ parts = file_path.split("/")
706
+
707
+ for i in range(len(parts)):
708
+ # For directories, we need to process all parts
709
+ # For files, we need to process all parts except the last one (handled separately)
710
+ if file_type == "directory" or i < len(parts) - 1:
711
+ parent_path = "/".join(parts[:i])
712
+ current_path = "/".join(parts[: i + 1])
713
+
714
+ # Create node if it doesn't exist
715
+ if current_path not in directory_nodes:
716
+ parent_node = directory_nodes[parent_path]
717
+ new_node = parent_node.add(parts[i], expand=True)
718
+ new_node.label.stylize(
719
+ "bold #bb9af7"
720
+ ) # Color directories with accent color
721
+ directory_nodes[current_path] = new_node
722
+
723
+ # For files, add as leaf node under the appropriate directory
724
+ if file_type == "file":
725
+ # Get the parent directory node
726
+ parent_dir_path = "/".join(parts[:-1])
727
+ parent_node = (
728
+ directory_nodes[parent_dir_path]
729
+ if parent_dir_path
730
+ else tree.root
731
+ )
732
+
733
+ leaf_node = parent_node.add_leaf(
734
+ parts[-1], data={"path": file_path, "status": git_status}
735
+ )
736
+ # Apply specific text colors based on git status
737
+ if git_status == "staged":
738
+ leaf_node.label.stylize("bold #9ece6a")
739
+ elif git_status == "modified":
740
+ leaf_node.label.stylize("bold #a9a1e1")
741
+ elif git_status == "untracked":
742
+ leaf_node.label.stylize("bold purple")
743
+ else: # unchanged
744
+ leaf_node.label.stylize("default")
745
+
746
+ except Exception as e:
747
+ # Show error in diff panel
748
+ try:
749
+ diff_content = self.query_one("#diff-content", VerticalScroll)
750
+ diff_content.remove_children()
751
+ diff_content.mount(
752
+ Static(f"Error populating file tree: {e}", classes="error")
753
+ )
754
+ except Exception:
755
+ # If we can't even show the error, that's okay - just continue without it
756
+ pass
757
+
758
+ def populate_unstaged_changes(self, file_data: Optional[Dict] = None) -> None:
759
+ """Populate the unstaged changes tree in the right sidebar."""
760
+ if not self.git_sidebar.repo:
761
+ return
762
+
763
+ file_data = file_data or self.git_sidebar.collect_file_data()
764
+ try:
765
+ # Get the unstaged tree widget
766
+ tree = self.query_one("#unstaged-tree", Tree)
767
+
768
+ # Clear existing tree
769
+ tree.clear()
770
+
771
+ # Automatically expand the root node
772
+ tree.root.expand()
773
+
774
+ # Use pre-fetched unstaged files
775
+ unstaged_files = file_data["unstaged_files"]
776
+ untracked_files = set(file_data["untracked_files"])
777
+
778
+ # Sort unstaged_files so directories are processed first
779
+ unstaged_files.sort()
780
+
781
+ # Keep track of created directory nodes to avoid duplicates
782
+ directory_nodes = {"": tree.root} # Empty string maps to root node
783
+
784
+ # Add unstaged files to tree with directory structure
785
+ for file_path in unstaged_files:
786
+ parts = file_path.split("/")
787
+ file_name = parts[-1]
788
+
789
+ # Determine file status from pre-fetched data
790
+ status = "untracked" if file_path in untracked_files else "modified"
791
+
792
+ # Build intermediate directory nodes as needed
793
+ for i in range(len(parts) - 1):
794
+ parent_path = "/".join(parts[:i])
795
+ current_path = "/".join(parts[: i + 1])
796
+
797
+ # Create node if it doesn't exist
798
+ if current_path not in directory_nodes:
799
+ parent_node = directory_nodes[parent_path]
800
+ new_node = parent_node.add(parts[i], expand=True)
801
+ new_node.label.stylize(
802
+ "bold #bb9af7"
803
+ ) # Color directories with accent color
804
+ directory_nodes[current_path] = new_node
805
+
806
+ # Add file as leaf node under the appropriate directory
807
+ parent_dir_path = "/".join(parts[:-1])
808
+ parent_node = (
809
+ directory_nodes[parent_dir_path] if parent_dir_path else tree.root
810
+ )
811
+
812
+ leaf_node = parent_node.add_leaf(
813
+ file_name, data={"path": file_path, "status": status}
814
+ )
815
+
816
+ # Apply styling based on status
817
+ if status == "modified":
818
+ leaf_node.label.stylize("bold #a9a1e1")
819
+ else: # untracked
820
+ leaf_node.label.stylize("bold purple")
821
+
822
+ except Exception as e:
823
+ # Show error in diff panel
824
+ try:
825
+ diff_content = self.query_one("#diff-content", VerticalScroll)
826
+ diff_content.remove_children()
827
+ diff_content.mount(
828
+ Static(f"Error populating unstaged changes: {e}", classes="error")
829
+ )
830
+ except Exception:
831
+ pass
832
+
833
+ def populate_staged_changes(self, file_data: Optional[Dict] = None) -> None:
834
+ """Populate the staged changes tree in the right sidebar."""
835
+ if not self.git_sidebar.repo:
836
+ return
837
+
838
+ file_data = file_data or self.git_sidebar.collect_file_data()
839
+ try:
840
+ # Get the staged tree widget
841
+ tree = self.query_one("#staged-tree", Tree)
842
+
843
+ # Clear existing tree
844
+ tree.clear()
845
+
846
+ # Automatically expand the root node
847
+ tree.root.expand()
848
+
849
+ # Use pre-fetched staged files
850
+ staged_files = file_data["staged_files"]
851
+
852
+ # Sort staged_files so directories are processed first
853
+ staged_files.sort()
854
+
855
+ # Keep track of created directory nodes to avoid duplicates
856
+ directory_nodes = {"": tree.root} # Empty string maps to root node
857
+
858
+ # Add staged files with directory structure
859
+ for file_path in staged_files:
860
+ parts = file_path.split("/")
861
+ file_name = parts[-1]
862
+
863
+ # Build intermediate directory nodes as needed
864
+ for i in range(len(parts) - 1):
865
+ parent_path = "/".join(parts[:i])
866
+ current_path = "/".join(parts[: i + 1])
867
+
868
+ # Create node if it doesn't exist
869
+ if current_path not in directory_nodes:
870
+ parent_node = directory_nodes[parent_path]
871
+ new_node = parent_node.add(parts[i], expand=True)
872
+ new_node.label.stylize(
873
+ "bold #bb9af7"
874
+ ) # Color directories with accent color
875
+ directory_nodes[current_path] = new_node
876
+
877
+ # Add file as leaf node under the appropriate directory
878
+ parent_dir_path = "/".join(parts[:-1])
879
+ parent_node = (
880
+ directory_nodes[parent_dir_path] if parent_dir_path else tree.root
881
+ )
882
+
883
+ leaf_node = parent_node.add_leaf(
884
+ file_name, data={"path": file_path, "status": "staged"}
885
+ )
886
+ leaf_node.label.stylize("bold #9ece6a")
887
+
888
+ except Exception as e:
889
+ # Show error in diff panel
890
+ try:
891
+ diff_content = self.query_one("#diff-content", VerticalScroll)
892
+ diff_content.remove_children()
893
+ diff_content.mount(
894
+ Static(f"Error populating staged changes: {e}", classes="error")
895
+ )
896
+ except Exception:
897
+ pass
898
+
899
+ def stage_hunk(self, file_path: str, hunk_index: int) -> None:
900
+ """Stage a specific hunk of a file."""
901
+ try:
902
+ success = self.git_sidebar.stage_hunk(file_path, hunk_index)
903
+
904
+ if success:
905
+ # Clear any cached diff state
906
+ if hasattr(self, "_current_displayed_file"):
907
+ delattr(self, "_current_displayed_file")
908
+ if hasattr(self, "_current_displayed_is_staged"):
909
+ delattr(self, "_current_displayed_is_staged")
910
+
911
+ # Refresh tree states with latest git data
912
+ file_data = self.git_sidebar.collect_file_data()
913
+ self.populate_unstaged_changes(file_data)
914
+ self.populate_staged_changes(file_data)
915
+
916
+ # Refresh only the diff view for the current file
917
+ if self.current_file:
918
+ self.display_file_diff(
919
+ self.current_file, self.current_is_staged, force_refresh=True
920
+ )
921
+
922
+ # Schedule a background refresh of file trees (non-blocking)
923
+ self.call_later(self._refresh_trees_async)
924
+ else:
925
+ self.notify(f"Failed to stage {file_path}", severity="error")
926
+
927
+ except Exception as e:
928
+ self.notify(f"Error staging hunk: {e}", severity="error")
929
+
930
+ def stage_file(self, file_path: str) -> None:
931
+ """Stage all changes in a file."""
932
+ try:
933
+ success = self.git_sidebar.stage_file(file_path)
934
+ if success:
935
+ # Refresh trees
936
+ # Refresh diff view for the staged file
937
+ self.display_file_diff(file_path, is_staged=True, force_refresh=True)
938
+ else:
939
+ self.notify(
940
+ f"Failed to stage all changes in {file_path}", severity="error"
941
+ )
942
+ except Exception as e:
943
+ self.notify(f"Error staging file: {e}", severity="error")
944
+
945
+ def unstage_hunk(self, file_path: str, hunk_index: int) -> None:
946
+ """Unstage a specific hunk of a file."""
947
+ try:
948
+ success = self.git_sidebar.unstage_hunk(file_path, hunk_index)
949
+
950
+ if success:
951
+ # Refresh tree states with latest git data
952
+ file_data = self.git_sidebar.collect_file_data()
953
+ self.populate_unstaged_changes(file_data)
954
+ self.populate_staged_changes(file_data)
955
+
956
+ # Refresh only the diff view for the current file
957
+ if self.current_file:
958
+ self.display_file_diff(
959
+ self.current_file, self.current_is_staged, force_refresh=True
960
+ )
961
+
962
+ # Schedule a background refresh of file trees (non-blocking)
963
+ self.call_later(self._refresh_trees_async)
964
+ else:
965
+ self.notify(f"Failed to unstage {file_path}", severity="error")
966
+
967
+ except Exception as e:
968
+ self.notify(f"Error unstaging hunk: {e}", severity="error")
969
+
970
+ def discard_hunk(self, file_path: str, hunk_index: int) -> None:
971
+ """Discard changes in a specific hunk of a file."""
972
+ try:
973
+ success = self.git_sidebar.discard_hunk(file_path, hunk_index)
974
+
975
+ if success:
976
+ # Clear any cached diff state
977
+ if hasattr(self, "_current_displayed_file"):
978
+ delattr(self, "_current_displayed_file")
979
+ if hasattr(self, "_current_displayed_is_staged"):
980
+ delattr(self, "_current_displayed_is_staged")
981
+
982
+ # Refresh tree states with latest git data
983
+ file_data = self.git_sidebar.collect_file_data()
984
+ self.populate_unstaged_changes(file_data)
985
+ self.populate_staged_changes(file_data)
986
+
987
+ # Refresh only the diff view
988
+ if self.current_file:
989
+ self.display_file_diff(
990
+ self.current_file, self.current_is_staged, force_refresh=True
991
+ )
992
+
993
+ # Schedule a background refresh of file trees (non-blocking)
994
+ self.call_later(self._refresh_trees_async)
995
+ else:
996
+ self.notify(
997
+ f"Failed to discard changes in {file_path}", severity="error"
998
+ )
999
+
1000
+ except Exception as e:
1001
+ self.notify(f"Error discarding hunk: {e}", severity="error")
1002
+
1003
+ def _refresh_trees_async(self) -> None:
1004
+ """Background refresh of file trees to avoid blocking UI during hunk operations."""
1005
+ try:
1006
+ # Check if we have recently modified files to optimize the refresh
1007
+ if self.git_sidebar.has_recent_modifications():
1008
+ # For now, still do full refresh but in background
1009
+ # Future optimization: only update nodes for modified files
1010
+ self.populate_file_tree()
1011
+ self.populate_unstaged_changes()
1012
+ self.populate_staged_changes()
1013
+ else:
1014
+ # No recent changes, skip expensive operations
1015
+ pass
1016
+ except Exception:
1017
+ # Silently fail background operations to avoid disrupting user experience
1018
+ pass
1019
+
1020
+ def populate_commit_history(self) -> None:
1021
+ """Populate the commit history tab."""
1022
+ try:
1023
+ history_content = self.query_one("#history-content", VerticalScroll)
1024
+ history_content.remove_children()
1025
+
1026
+ branch_name = self.git_sidebar.get_current_branch()
1027
+ commits = self.git_sidebar.get_commit_history()
1028
+
1029
+ for commit in commits:
1030
+ # Display branch, commit ID, author, and message with colors that match our theme
1031
+ commit_text = f"[#87CEEB]{branch_name}[/#87CEEB] [#E0FFFF]{commit.sha}[/#E0FFFF] [#00BFFF]{commit.author}[/#00BFFF]: {commit.message}"
1032
+ commit_line = CommitLine(commit_text, classes="info")
1033
+ history_content.mount(commit_line)
1034
+
1035
+ except Exception:
1036
+ pass
1037
+
1038
+ def display_file_diff(
1039
+ self, file_path: str, is_staged: bool = False, force_refresh: bool = False
1040
+ ) -> None:
1041
+ """Display the diff for a selected file in the diff panel with appropriate buttons."""
1042
+ # Skip if this is the same file we're already displaying (unless force_refresh is True)
1043
+ if (
1044
+ not force_refresh
1045
+ and hasattr(self, "_current_displayed_file")
1046
+ and self._current_displayed_file == file_path
1047
+ and self._current_displayed_is_staged == is_staged
1048
+ ):
1049
+ return
1050
+ self.current_is_staged = is_staged
1051
+
1052
+ try:
1053
+ diff_content = self.query_one("#diff-content", VerticalScroll)
1054
+ # Ensure we're starting with a clean slate
1055
+ diff_content.remove_children()
1056
+
1057
+ # Track which file we're currently displaying
1058
+ self._current_displayed_file = file_path
1059
+ self._current_displayed_is_staged = is_staged
1060
+
1061
+ # Get file status to determine which buttons to show
1062
+ hunks = self.git_sidebar.get_diff_hunks(file_path, staged=is_staged)
1063
+
1064
+ if not hunks:
1065
+ diff_content.mount(Static("No changes to display", classes="info"))
1066
+ return
1067
+
1068
+ # Generate a unique timestamp for this refresh to avoid ID collisions
1069
+ refresh_id = str(int(time.time() * 1000000)) # microsecond timestamp
1070
+
1071
+ repo_root = getattr(self.git_sidebar, "repo_path", Path.cwd())
1072
+ markdown_config = DiffMarkdownConfig(
1073
+ repo_root=repo_root,
1074
+ prefer_diff_language=False,
1075
+ show_headers=False,
1076
+ )
1077
+
1078
+ # Display each hunk
1079
+ for i, hunk in enumerate(hunks):
1080
+ hunk_header = Static(hunk.header, classes="hunk-header")
1081
+
1082
+ markdown_widget = DiffMarkdown(
1083
+ file_path=file_path,
1084
+ hunks=[hunk],
1085
+ config=markdown_config,
1086
+ )
1087
+ markdown_widget.add_class("diff-markdown")
1088
+
1089
+ sanitized_file_path = (
1090
+ file_path.replace("/", "__SLASH__")
1091
+ .replace(" ", "__SPACE__")
1092
+ .replace(".", "__DOT__")
1093
+ )
1094
+ hunk_children = [hunk_header, markdown_widget]
1095
+
1096
+ if self._hunk_has_changes(hunk):
1097
+ if is_staged:
1098
+ hunk_children.append(
1099
+ Horizontal(
1100
+ Button(
1101
+ "Unstage",
1102
+ id=f"unstage-hunk-{i}-{sanitized_file_path}-{refresh_id}",
1103
+ classes="unstage-button",
1104
+ ),
1105
+ classes="hunk-buttons",
1106
+ )
1107
+ )
1108
+ else:
1109
+ hunk_children.append(
1110
+ Horizontal(
1111
+ Button(
1112
+ "Stage",
1113
+ id=f"stage-hunk-{i}-{sanitized_file_path}-{refresh_id}",
1114
+ classes="stage-button",
1115
+ ),
1116
+ Button(
1117
+ "Discard",
1118
+ id=f"discard-hunk-{i}-{sanitized_file_path}-{refresh_id}",
1119
+ classes="discard-button",
1120
+ ),
1121
+ classes="hunk-buttons",
1122
+ )
1123
+ )
1124
+
1125
+ hunk_container = Container(
1126
+ *hunk_children,
1127
+ id=f"{'staged' if is_staged else 'unstaged'}-hunk-{i}-{sanitized_file_path}-{refresh_id}",
1128
+ classes="hunk-container",
1129
+ )
1130
+
1131
+ diff_content.mount(hunk_container)
1132
+
1133
+ except Exception as e:
1134
+ self.notify(f"Error displaying diff: {e}", severity="error")
1135
+
1136
+ def action_commit(self) -> None:
1137
+ """Commit staged changes with a commit message from the UI."""
1138
+ try:
1139
+ # Get the commit message input widgets
1140
+ commit_input = self.query_one("#commit-message", Input)
1141
+ commit_body = self.query_one("#commit-body", TextArea)
1142
+
1143
+ subject = commit_input.value.strip()
1144
+ body = commit_body.text.strip()
1145
+
1146
+ # Combine subject and body for full commit message
1147
+ message = subject
1148
+ if body:
1149
+ message = f"{subject}\n\n{body}"
1150
+
1151
+ # Check if there's a commit message
1152
+ if not subject:
1153
+ self.notify("Please enter a commit message", severity="warning")
1154
+ return
1155
+
1156
+ # Check if there are staged changes
1157
+ staged_files = self.git_sidebar.get_staged_files()
1158
+ if not staged_files:
1159
+ self.notify("No staged changes to commit", severity="warning")
1160
+ return
1161
+
1162
+ # Attempt to commit staged changes
1163
+ success = self.git_sidebar.commit_staged_changes(message)
1164
+
1165
+ if success:
1166
+ self.notify(
1167
+ f"Successfully committed changes with message: {message}",
1168
+ severity="information",
1169
+ )
1170
+ # Clear the commit message inputs
1171
+ commit_input.value = ""
1172
+ commit_body.text = ""
1173
+
1174
+ # Rebuild tree states with latest git data
1175
+ file_data = self.git_sidebar.collect_file_data()
1176
+ self.populate_file_tree()
1177
+ self.populate_unstaged_changes(file_data)
1178
+ self.populate_staged_changes(file_data)
1179
+
1180
+ # Refresh the diff and commit history views
1181
+ if self.current_file:
1182
+ self.display_file_diff(
1183
+ self.current_file, self.current_is_staged, force_refresh=True
1184
+ )
1185
+ self.populate_commit_history()
1186
+ else:
1187
+ self.notify("Failed to commit changes", severity="error")
1188
+
1189
+ except Exception as e:
1190
+ self.notify(f"Error committing changes: {e}", severity="error")
1191
+
1192
+ def action_push_changes(self) -> None:
1193
+ """Push the current branch to its remote."""
1194
+ try:
1195
+ success, message = self.git_sidebar.push_current_branch()
1196
+ if success:
1197
+ self.notify(f"🚀 {message}", severity="information")
1198
+ else:
1199
+ self.notify(message, severity="error")
1200
+ except Exception as e:
1201
+ self.notify(f"Push blew up: {e}", severity="error")
1202
+
1203
+ def action_pull_changes(self) -> None:
1204
+ """Pull the latest changes for the current branch."""
1205
+ try:
1206
+ success, message = self.git_sidebar.pull_current_branch()
1207
+ if success:
1208
+ self.notify(f"📥 {message}", severity="information")
1209
+ # Refresh trees and history to reflect new changes
1210
+ file_data = self.git_sidebar.collect_file_data()
1211
+ self.populate_file_tree()
1212
+ self.populate_unstaged_changes(file_data)
1213
+ self.populate_staged_changes(file_data)
1214
+ self.populate_commit_history()
1215
+ if self.current_file:
1216
+ self.display_file_diff(
1217
+ self.current_file, self.current_is_staged, force_refresh=True
1218
+ )
1219
+ else:
1220
+ self.notify(message, severity="error")
1221
+ except Exception as e:
1222
+ self.notify(f"Pull imploded: {e}", severity="error")
1223
+
1224
+ def action_gac_config(self) -> None:
1225
+ """Show GAC configuration modal."""
1226
+
1227
+ def handle_config_result(result):
1228
+ # Refresh GAC integration after config changes
1229
+ self.gac_integration = GACIntegration(self.git_sidebar)
1230
+
1231
+ self.push_screen(GACConfigModal(), handle_config_result)
1232
+
1233
+ def action_stage_selected_file(self) -> None:
1234
+ """Stage the entire currently selected file from any file tree if it is unstaged/untracked."""
1235
+ try:
1236
+ if not self.current_file:
1237
+ self.notify("No file selected", severity="warning")
1238
+ return
1239
+ status = self.git_sidebar.get_file_status(self.current_file)
1240
+ # Allow staging even if file is partially staged; block only if unchanged
1241
+ if "unchanged" in status:
1242
+ self.notify("Selected file has no changes", severity="information")
1243
+ return
1244
+
1245
+ # Perform the staging operation
1246
+ success = self.git_sidebar.stage_file(self.current_file)
1247
+ if success:
1248
+ # Use the comprehensive refresh function
1249
+ self.action_refresh_branches()
1250
+ # Also refresh diff view for the staged file
1251
+ self.display_file_diff(
1252
+ self.current_file, is_staged=True, force_refresh=True
1253
+ )
1254
+ else:
1255
+ self.notify(
1256
+ f"Failed to stage all changes in {self.current_file}",
1257
+ severity="error",
1258
+ )
1259
+ except Exception as e:
1260
+ self.notify(f"Error staging selected file: {e}", severity="error")
1261
+
1262
+ def action_unstage_selected_file(self) -> None:
1263
+ """Unstage all changes for the selected file (if staged)."""
1264
+ try:
1265
+ if not self.current_file:
1266
+ self.notify("No file selected", severity="warning")
1267
+ return
1268
+ status = self.git_sidebar.get_file_status(self.current_file)
1269
+ if "staged" not in status:
1270
+ self.notify("Selected file is not staged", severity="information")
1271
+ return
1272
+
1273
+ # Perform the unstaging operation
1274
+ if hasattr(self.git_sidebar, "unstage_file_all") and callable(
1275
+ self.git_sidebar.unstage_file_all
1276
+ ):
1277
+ success = self.git_sidebar.unstage_file_all(self.current_file)
1278
+ else:
1279
+ # Fallback: remove entire file from index
1280
+ success = self.git_sidebar.unstage_file(self.current_file)
1281
+
1282
+ if success:
1283
+ # Use the comprehensive refresh function
1284
+ self.action_refresh_branches()
1285
+ # Also refresh diff view to show unstaged changes
1286
+ if self.current_file:
1287
+ self.display_file_diff(
1288
+ self.current_file, is_staged=False, force_refresh=True
1289
+ )
1290
+ else:
1291
+ self.notify(f"Failed to unstage {self.current_file}", severity="error")
1292
+ except Exception as e:
1293
+ self.notify(f"Error unstaging selected file: {e}", severity="error")
1294
+
1295
+ def action_show_help(self) -> None:
1296
+ """Show the help modal with keybindings."""
1297
+ try:
1298
+ help_modal = HelpModal()
1299
+ self.push_screen(help_modal)
1300
+ except Exception as e:
1301
+ self.notify(f"Error showing help: {e}", severity="error")
1302
+
1303
+ def action_stage_all(self) -> None:
1304
+ """Stage all unstaged changes."""
1305
+ try:
1306
+ success, message = self.git_sidebar.stage_all_changes()
1307
+ if success:
1308
+ # Refresh UI
1309
+ self.populate_file_tree()
1310
+ if self.current_file:
1311
+ self.display_file_diff(
1312
+ self.current_file, is_staged=True, force_refresh=True
1313
+ )
1314
+ else:
1315
+ self.notify(message, severity="error")
1316
+ except Exception as e:
1317
+ self.notify(f"Error staging all changes: {e}", severity="error")
1318
+
1319
+ def action_unstage_all(self) -> None:
1320
+ """Unstage all staged changes."""
1321
+ try:
1322
+ success, message = self.git_sidebar.unstage_all_changes()
1323
+ if success:
1324
+ # Refresh UI
1325
+ self.populate_file_tree()
1326
+ if self.current_file:
1327
+ self.display_file_diff(
1328
+ self.current_file, is_staged=False, force_refresh=True
1329
+ )
1330
+ else:
1331
+ self.notify(message, severity="error")
1332
+ except Exception as e:
1333
+ self.notify(f"Error unstaging all changes: {e}", severity="error")
1334
+
1335
+ def action_switch_to_unstaged(self) -> None:
1336
+ """Switch to the Unstaged Changes tab."""
1337
+ try:
1338
+ status_tabs = self.query_one("#status-tabs", TabbedContent)
1339
+ status_tabs.active = "unstaged-tab"
1340
+ except Exception as e:
1341
+ self.notify(f"Error switching to unstaged tab: {e}", severity="error")
1342
+
1343
+ def action_switch_to_staged(self) -> None:
1344
+ """Switch to the Staged Changes tab."""
1345
+ try:
1346
+ status_tabs = self.query_one("#status-tabs", TabbedContent)
1347
+ status_tabs.active = "staged-tab"
1348
+ except Exception as e:
1349
+ self.notify(f"Error switching to staged tab: {e}", severity="error")
1350
+
1351
+ def action_gac_generate(self) -> None:
1352
+ """Generate commit message using GAC and populate the commit message fields (no auto-commit)."""
1353
+ try:
1354
+ if not self.gac_integration.is_configured():
1355
+ self.notify(
1356
+ "🤖 GAC is not configured. Press Ctrl+G to configure it first.",
1357
+ severity="warning",
1358
+ )
1359
+ return
1360
+
1361
+ # Check if there are staged changes
1362
+ staged_files = self.git_sidebar.get_staged_files()
1363
+ if not staged_files:
1364
+ self.notify(
1365
+ "No staged changes to generate commit message for",
1366
+ severity="warning",
1367
+ )
1368
+ return
1369
+
1370
+ # Show generating message
1371
+ self.notify(
1372
+ "🤖 Generating commit message with GAC...", severity="information"
1373
+ )
1374
+
1375
+ # Generate commit message
1376
+ try:
1377
+ commit_message = self.gac_integration.generate_commit_message(
1378
+ staged_only=True, one_liner=False
1379
+ )
1380
+
1381
+ if commit_message:
1382
+ # Parse the commit message into subject and body
1383
+ lines = commit_message.strip().split("\n", 1)
1384
+ subject = lines[0].strip()
1385
+ body = lines[1].strip() if len(lines) > 1 else ""
1386
+
1387
+ # Populate the commit message inputs
1388
+ try:
1389
+ commit_input = self.query_one("#commit-message", Input)
1390
+ commit_body = self.query_one("#commit-body", TextArea)
1391
+
1392
+ commit_input.value = subject
1393
+ commit_body.text = body
1394
+
1395
+ self.notify(
1396
+ f"✅ GAC generated commit message: {subject[:50]}...",
1397
+ severity="information",
1398
+ )
1399
+
1400
+ except Exception as e:
1401
+ self.notify(
1402
+ f"Generated message but failed to populate fields: {e}",
1403
+ severity="warning",
1404
+ )
1405
+ else:
1406
+ self.notify(
1407
+ "❌ GAC failed to generate a commit message", severity="error"
1408
+ )
1409
+
1410
+ except Exception as e:
1411
+ self.notify(
1412
+ f"❌ Failed to generate commit message: {e}", severity="error"
1413
+ )
1414
+
1415
+ except Exception as e:
1416
+ self.notify(f"❌ Error with GAC integration: {e}", severity="error")