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.
- octotui/__init__.py +1 -0
- octotui/__main__.py +6 -0
- octotui/custom_figlet_widget.py +0 -0
- octotui/diff_markdown.py +187 -0
- octotui/gac_config_modal.py +369 -0
- octotui/gac_integration.py +183 -0
- octotui/gac_provider_registry.py +199 -0
- octotui/git_diff_viewer.py +1346 -0
- octotui/git_status_sidebar.py +1010 -0
- octotui/main.py +15 -0
- octotui/octotui_logo.py +52 -0
- octotui/style.tcss +431 -0
- octotui/syntax_utils.py +0 -0
- octotui-0.1.1.data/data/octotui/style.tcss +431 -0
- octotui-0.1.1.dist-info/METADATA +207 -0
- octotui-0.1.1.dist-info/RECORD +18 -0
- octotui-0.1.1.dist-info/WHEEL +4 -0
- octotui-0.1.1.dist-info/entry_points.txt +2 -0
|
@@ -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")
|