gitux 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitux/__init__.py +3 -0
- gitux/__main__.py +6 -0
- gitux/cli.py +35 -0
- gitux/domain/__init__.py +27 -0
- gitux/domain/models.py +180 -0
- gitux/git/__init__.py +57 -0
- gitux/git/commands.py +392 -0
- gitux/git/exceptions.py +9 -0
- gitux/git/parser.py +83 -0
- gitux/presenter/__init__.py +6 -0
- gitux/presenter/commit_presenter.py +107 -0
- gitux/presenter/repo_presenter.py +119 -0
- gitux/ui/__init__.py +1 -0
- gitux/ui/app.py +452 -0
- gitux/ui/gitux.tcss +323 -0
- gitux/ui/widgets/__init__.py +24 -0
- gitux/ui/widgets/block_container.py +61 -0
- gitux/ui/widgets/branch_screen.py +54 -0
- gitux/ui/widgets/changed_files.py +187 -0
- gitux/ui/widgets/commit_log.py +197 -0
- gitux/ui/widgets/commit_screen.py +110 -0
- gitux/ui/widgets/diff_viewer.py +149 -0
- gitux/ui/widgets/gitux_footer.py +44 -0
- gitux/ui/widgets/header.py +81 -0
- gitux/ui/widgets/help_screen.py +74 -0
- gitux/ui/widgets/repo_stats_bar.py +212 -0
- gitux/utils/__init__.py +1 -0
- gitux-0.1.0.dist-info/METADATA +174 -0
- gitux-0.1.0.dist-info/RECORD +32 -0
- gitux-0.1.0.dist-info/WHEEL +4 -0
- gitux-0.1.0.dist-info/entry_points.txt +2 -0
- gitux-0.1.0.dist-info/licenses/LICENSE +21 -0
gitux/ui/gitux.tcss
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
Screen {
|
|
2
|
+
background: #13131b;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
* {
|
|
6
|
+
color: #e4e1ed;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
#top-bar {
|
|
10
|
+
dock: top;
|
|
11
|
+
height: 1;
|
|
12
|
+
background: #1f1f27;
|
|
13
|
+
color: #e4e1ed;
|
|
14
|
+
padding: 0 1;
|
|
15
|
+
text-style: bold;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#stats-bar {
|
|
19
|
+
dock: top;
|
|
20
|
+
height: 1;
|
|
21
|
+
background: #1f1f27;
|
|
22
|
+
color: #c7c4d7;
|
|
23
|
+
padding: 0 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#main-content {
|
|
27
|
+
height: 1fr;
|
|
28
|
+
layout: vertical;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#bento-top {
|
|
32
|
+
height: 1fr;
|
|
33
|
+
layout: horizontal;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#files-block {
|
|
37
|
+
width: 1fr;
|
|
38
|
+
min-width: 30;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#commits-block {
|
|
42
|
+
height: 1fr;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#commit-log {
|
|
46
|
+
height: 1fr;
|
|
47
|
+
overflow-y: auto;
|
|
48
|
+
overflow-x: hidden;
|
|
49
|
+
padding: 0 1;
|
|
50
|
+
color: #e4e1ed;
|
|
51
|
+
background: transparent;
|
|
52
|
+
scrollbar-visibility: hidden;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.block-active {
|
|
56
|
+
border: solid #c0c1ff;
|
|
57
|
+
background: #0d0d15;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.block-inactive {
|
|
61
|
+
border: solid #464554;
|
|
62
|
+
background: #0d0d15;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.block-header {
|
|
66
|
+
height: 1;
|
|
67
|
+
background: #34343d;
|
|
68
|
+
padding: 0 1;
|
|
69
|
+
text-style: bold;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.block-active .block-header {
|
|
73
|
+
color: #e4e1ed;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.block-inactive .block-header {
|
|
77
|
+
color: #c7c4d7;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#changed-files {
|
|
81
|
+
height: 1fr;
|
|
82
|
+
overflow-y: auto;
|
|
83
|
+
overflow-x: hidden;
|
|
84
|
+
padding: 0 1;
|
|
85
|
+
background: transparent;
|
|
86
|
+
scrollbar-visibility: hidden;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.staged-section {
|
|
90
|
+
color: #c0c1ff;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.unstaged-section {
|
|
94
|
+
color: #ffb783;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.file-status-M { color: #ffb783; }
|
|
98
|
+
.file-status-A { color: #10b981; }
|
|
99
|
+
.file-status-D { color: #ffb4ab; }
|
|
100
|
+
.file-status-R { color: #c0c1ff; }
|
|
101
|
+
.file-status-C { color: #c0c1ff; }
|
|
102
|
+
.file-status-unknown { color: #908fa0; }
|
|
103
|
+
|
|
104
|
+
#diff-viewer {
|
|
105
|
+
height: 1fr;
|
|
106
|
+
overflow-y: auto;
|
|
107
|
+
padding: 0 1;
|
|
108
|
+
background: transparent;
|
|
109
|
+
scrollbar-visibility: hidden;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
GituxFooter {
|
|
113
|
+
dock: bottom;
|
|
114
|
+
height: 1;
|
|
115
|
+
background: #1f1f27;
|
|
116
|
+
color: #c7c4d7;
|
|
117
|
+
padding: 0 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#commit-modal {
|
|
121
|
+
background: #34343d;
|
|
122
|
+
border: solid #c0c1ff;
|
|
123
|
+
color: #e4e1ed;
|
|
124
|
+
width: 60%;
|
|
125
|
+
height: 13;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#help-container {
|
|
129
|
+
background: #34343d;
|
|
130
|
+
border: solid #c0c1ff;
|
|
131
|
+
color: #e4e1ed;
|
|
132
|
+
width: 64;
|
|
133
|
+
max-width: 90%;
|
|
134
|
+
max-height: 85%;
|
|
135
|
+
padding: 1 2;
|
|
136
|
+
overflow-y: auto;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#help-title {
|
|
140
|
+
text-style: bold;
|
|
141
|
+
color: #c0c1ff;
|
|
142
|
+
text-align: center;
|
|
143
|
+
margin: 0 0 1 0;
|
|
144
|
+
content-align: center middle;
|
|
145
|
+
height: 1;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#help-body {
|
|
149
|
+
color: #e4e1ed;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#help-body .key {
|
|
153
|
+
color: #ffb783;
|
|
154
|
+
text-style: bold;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#help-close-hint {
|
|
158
|
+
text-style: italic;
|
|
159
|
+
color: #c7c4d7;
|
|
160
|
+
text-align: center;
|
|
161
|
+
margin: 1 0 0 0;
|
|
162
|
+
height: 1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
CommitScreen {
|
|
166
|
+
background: #13131b 85%;
|
|
167
|
+
align: center middle;
|
|
168
|
+
height: 13;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#commit-modal-type {
|
|
172
|
+
height: 1;
|
|
173
|
+
text-align: center;
|
|
174
|
+
color: #ffb783;
|
|
175
|
+
margin: 0 0 1 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#commit-modal-title {
|
|
179
|
+
text-style: bold;
|
|
180
|
+
color: #c0c1ff;
|
|
181
|
+
margin: 0 0 1 0;
|
|
182
|
+
height: 1;
|
|
183
|
+
text-align: center;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#commit-modal-input {
|
|
187
|
+
width: 100%;
|
|
188
|
+
height: 3;
|
|
189
|
+
background: #1f1f27;
|
|
190
|
+
color: #e4e1ed;
|
|
191
|
+
border: solid #464554;
|
|
192
|
+
padding: 0 1;
|
|
193
|
+
margin: 0 0 1 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#commit-modal-input:focus {
|
|
197
|
+
border: solid #c0c1ff;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#commit-modal-input .input--cursor {
|
|
201
|
+
color: #13131b;
|
|
202
|
+
background: #c0c1ff;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#commit-modal-buttons {
|
|
206
|
+
layout: horizontal;
|
|
207
|
+
height: 3;
|
|
208
|
+
align: center middle;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#commit-modal-btn-commit {
|
|
212
|
+
width: 16;
|
|
213
|
+
min-width: 16;
|
|
214
|
+
max-width: 16;
|
|
215
|
+
margin: 0 1 0 0;
|
|
216
|
+
background: #10b981;
|
|
217
|
+
color: #0d0d15;
|
|
218
|
+
text-style: bold;
|
|
219
|
+
border: none;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#commit-modal-btn-commit:hover {
|
|
223
|
+
background: #34d399;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#commit-modal-btn-commit:disabled {
|
|
227
|
+
background: #1f1f27;
|
|
228
|
+
color: #c7c4d7;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#commit-modal-btn-cancel {
|
|
232
|
+
width: 16;
|
|
233
|
+
min-width: 16;
|
|
234
|
+
max-width: 16;
|
|
235
|
+
background: transparent;
|
|
236
|
+
color: #908fa0;
|
|
237
|
+
text-style: bold;
|
|
238
|
+
border: none;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#commit-modal-btn-cancel:hover {
|
|
242
|
+
background: #1f1f27;
|
|
243
|
+
color: #e4e1ed;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
HelpScreen {
|
|
247
|
+
background: #13131b 80%;
|
|
248
|
+
align: center middle;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
BranchScreen {
|
|
252
|
+
background: #13131b 85%;
|
|
253
|
+
align: center middle;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#branch-modal {
|
|
257
|
+
background: #34343d;
|
|
258
|
+
border: solid #c0c1ff;
|
|
259
|
+
color: #e4e1ed;
|
|
260
|
+
width: 50;
|
|
261
|
+
max-width: 90%;
|
|
262
|
+
height: auto;
|
|
263
|
+
padding: 1 2;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
#branch-modal-title {
|
|
267
|
+
text-style: bold;
|
|
268
|
+
color: #c0c1ff;
|
|
269
|
+
text-align: center;
|
|
270
|
+
height: 1;
|
|
271
|
+
margin: 0 0 1 0;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#branch-option-list {
|
|
275
|
+
background: #1f1f27;
|
|
276
|
+
color: #e4e1ed;
|
|
277
|
+
border: solid #464554;
|
|
278
|
+
height: auto;
|
|
279
|
+
max-height: 60%;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#branch-option-list > .option-list--option-highlighted {
|
|
283
|
+
background: #3a4a5f;
|
|
284
|
+
color: #e4e1ed;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#branch-modal-hint {
|
|
288
|
+
text-style: italic;
|
|
289
|
+
color: #c7c4d7;
|
|
290
|
+
text-align: center;
|
|
291
|
+
height: 1;
|
|
292
|
+
margin: 1 0 0 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
Toast {
|
|
296
|
+
background: #1f1f27;
|
|
297
|
+
border: solid #464554;
|
|
298
|
+
padding: 0 1;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
Toast.-information {
|
|
302
|
+
border: solid #10b981;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
Toast.-information .toast-title {
|
|
306
|
+
color: #10b981;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
Toast.-error {
|
|
310
|
+
border: solid #ffb4ab;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
Toast.-error .toast-title {
|
|
314
|
+
color: #ffb4ab;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
Toast.-warning {
|
|
318
|
+
border: solid #ffb783;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
Toast.-warning .toast-title {
|
|
322
|
+
color: #ffb783;
|
|
323
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from gitux.ui.widgets.block_container import BlockContainer
|
|
2
|
+
from gitux.ui.widgets.branch_screen import BranchScreen
|
|
3
|
+
from gitux.ui.widgets.changed_files import ChangedFilesPanel
|
|
4
|
+
from gitux.ui.widgets.commit_log import CommitLogWidget, extract_commit_hash
|
|
5
|
+
from gitux.ui.widgets.commit_screen import CommitScreen
|
|
6
|
+
from gitux.ui.widgets.diff_viewer import DiffViewerWidget
|
|
7
|
+
from gitux.ui.widgets.gitux_footer import GituxFooter
|
|
8
|
+
from gitux.ui.widgets.header import TopAppBar
|
|
9
|
+
from gitux.ui.widgets.help_screen import HelpScreen
|
|
10
|
+
from gitux.ui.widgets.repo_stats_bar import RepoStatsBar
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BlockContainer",
|
|
14
|
+
"BranchScreen",
|
|
15
|
+
"ChangedFilesPanel",
|
|
16
|
+
"CommitLogWidget",
|
|
17
|
+
"CommitScreen",
|
|
18
|
+
"DiffViewerWidget",
|
|
19
|
+
"GituxFooter",
|
|
20
|
+
"HelpScreen",
|
|
21
|
+
"RepoStatsBar",
|
|
22
|
+
"TopAppBar",
|
|
23
|
+
"extract_commit_hash",
|
|
24
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Block container — wraps a content widget with a styled header and active state."""
|
|
2
|
+
|
|
3
|
+
from typing import override
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.widget import Widget
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BlockContainer(Vertical):
|
|
12
|
+
"""Bento-grid block that wraps a content widget with a styled title header.
|
|
13
|
+
|
|
14
|
+
Supports active/inactive visual states via CSS classes (``.block-active`` /
|
|
15
|
+
``.block-inactive``) for keyboard focus indication.
|
|
16
|
+
|
|
17
|
+
Used to wrap ChangedFilesPanel, CommitLogWidget, and DiffViewerWidget in the
|
|
18
|
+
bento layout.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
title: str,
|
|
24
|
+
subtitle: str = "",
|
|
25
|
+
content_widget: Widget | None = None,
|
|
26
|
+
name: str | None = None,
|
|
27
|
+
id: str | None = None,
|
|
28
|
+
classes: str | None = None,
|
|
29
|
+
disabled: bool = False,
|
|
30
|
+
) -> None:
|
|
31
|
+
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
|
32
|
+
self._title: str = title
|
|
33
|
+
self._subtitle: str = subtitle
|
|
34
|
+
self._content_widget: Widget | None = content_widget
|
|
35
|
+
self._active: bool = False
|
|
36
|
+
_ = self.set_class(True, "block-inactive")
|
|
37
|
+
|
|
38
|
+
@override
|
|
39
|
+
def compose(self) -> ComposeResult:
|
|
40
|
+
"""Yield the header static and the wrapped content widget."""
|
|
41
|
+
yield Static(self._render_header(), classes="block-header")
|
|
42
|
+
if self._content_widget:
|
|
43
|
+
yield self._content_widget
|
|
44
|
+
|
|
45
|
+
def set_active(self, active: bool) -> None:
|
|
46
|
+
"""Toggle the active state, which changes the border styling."""
|
|
47
|
+
self._active = active
|
|
48
|
+
_ = self.set_class(active, "block-active")
|
|
49
|
+
_ = self.set_class(not active, "block-inactive")
|
|
50
|
+
|
|
51
|
+
def set_header_subtitle(self, subtitle: str) -> None:
|
|
52
|
+
"""Update the subtitle text in the block header in-place."""
|
|
53
|
+
self._subtitle = subtitle
|
|
54
|
+
header = self.query_one(".block-header", Static)
|
|
55
|
+
header.update(self._render_header())
|
|
56
|
+
|
|
57
|
+
def _render_header(self) -> str:
|
|
58
|
+
"""Render the header line: ``Title / subtitle`` or just the title."""
|
|
59
|
+
if self._subtitle:
|
|
60
|
+
return f"{self._title} / {self._subtitle}"
|
|
61
|
+
return self._title
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Branch switch modal — lists local branches and switches on selection."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.binding import Binding
|
|
5
|
+
from textual.containers import Vertical
|
|
6
|
+
from textual.screen import ModalScreen
|
|
7
|
+
from textual.widgets import OptionList, Static
|
|
8
|
+
|
|
9
|
+
from gitux.git import GitError
|
|
10
|
+
from gitux.presenter.repo_presenter import RepoPresenter
|
|
11
|
+
from typing import override
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BranchScreen(ModalScreen[str]):
|
|
15
|
+
"""Modal listing local branches; Enter switches, Escape cancels."""
|
|
16
|
+
|
|
17
|
+
BINDINGS: list[Binding] = [Binding("escape", "cancel", "Cancel")]
|
|
18
|
+
|
|
19
|
+
def __init__(self, presenter: RepoPresenter) -> None:
|
|
20
|
+
super().__init__()
|
|
21
|
+
self.presenter: RepoPresenter = presenter
|
|
22
|
+
|
|
23
|
+
@override
|
|
24
|
+
def compose(self) -> ComposeResult:
|
|
25
|
+
"""Build the modal: title, branch list, and hint line."""
|
|
26
|
+
with Vertical(id="branch-modal"):
|
|
27
|
+
yield Static("Switch Branch", id="branch-modal-title")
|
|
28
|
+
yield OptionList(id="branch-option-list")
|
|
29
|
+
yield Static("Enter: switch \u2022 Esc: cancel", id="branch-modal-hint")
|
|
30
|
+
|
|
31
|
+
def on_mount(self) -> None:
|
|
32
|
+
"""Populate the branch list and focus it."""
|
|
33
|
+
option_list = self.query_one("#branch-option-list", OptionList)
|
|
34
|
+
branches = self.presenter.get_branches()
|
|
35
|
+
for name in branches:
|
|
36
|
+
_ = option_list.add_option(name)
|
|
37
|
+
if not branches:
|
|
38
|
+
_ = self.query_one("#branch-modal-hint", Static).update("No branches found")
|
|
39
|
+
else:
|
|
40
|
+
_ = option_list.focus()
|
|
41
|
+
|
|
42
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
43
|
+
"""Switch to the selected branch and dismiss, or notify on error."""
|
|
44
|
+
branch = str(event.option.prompt)
|
|
45
|
+
try:
|
|
46
|
+
self.presenter.switch_branch(branch)
|
|
47
|
+
except GitError as exc:
|
|
48
|
+
_ = self.notify(str(exc), severity="error")
|
|
49
|
+
return
|
|
50
|
+
_ = self.dismiss(branch)
|
|
51
|
+
|
|
52
|
+
def action_cancel(self) -> None:
|
|
53
|
+
"""Dismiss the modal without selecting a branch."""
|
|
54
|
+
_ = self.dismiss(None)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Changed files panel — staged/unstaged file list with cursor navigation."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.widgets import RichLog
|
|
7
|
+
|
|
8
|
+
from gitux.domain import FileStatus
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_STATUS_COLORS: dict[str, str] = {
|
|
12
|
+
"M": "#ffb783",
|
|
13
|
+
"A": "#10b981",
|
|
14
|
+
"D": "#ffb4ab",
|
|
15
|
+
"R": "#c0c1ff",
|
|
16
|
+
"C": "#c0c1ff",
|
|
17
|
+
"?": "#908fa0",
|
|
18
|
+
"!": "#c7c4d7",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
_MAX_PATH_CHARS: int = 40
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ChangedFilesPanel(RichLog):
|
|
25
|
+
"""File listing panel with staged and unstaged sections."""
|
|
26
|
+
|
|
27
|
+
can_focus = False
|
|
28
|
+
|
|
29
|
+
def __init__(self, **kwargs) -> None:
|
|
30
|
+
super().__init__(
|
|
31
|
+
highlight=False, markup=False, auto_scroll=False, max_lines=None, **kwargs
|
|
32
|
+
)
|
|
33
|
+
self._files: list[FileStatus] = []
|
|
34
|
+
self._cursor_index: int = 0
|
|
35
|
+
self._on_toggle: Callable[[FileStatus], None] | None = None
|
|
36
|
+
self._on_selection_change: Callable[[], None] | None = None
|
|
37
|
+
self._on_stage_all: Callable[[], None] | None = None
|
|
38
|
+
self._on_unstage_all: Callable[[], None] | None = None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def selected_file(self) -> FileStatus | None:
|
|
42
|
+
"""Return the :class:`FileStatus` under the cursor, or ``None``."""
|
|
43
|
+
all_files = self._all_files_flat
|
|
44
|
+
if 0 <= self._cursor_index < len(all_files):
|
|
45
|
+
return all_files[self._cursor_index]
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def _all_files_flat(self) -> list[FileStatus]:
|
|
50
|
+
"""Staged files followed by unstaged files, in cursor order."""
|
|
51
|
+
staged = [f for f in self._files if f.is_staged]
|
|
52
|
+
unstaged = [f for f in self._files if f.is_unstaged]
|
|
53
|
+
return staged + unstaged
|
|
54
|
+
|
|
55
|
+
def set_files(self, files: list[FileStatus]) -> None:
|
|
56
|
+
"""Replace the file list and reset cursor if out of bounds."""
|
|
57
|
+
self._files = files
|
|
58
|
+
total = len(self._all_files_flat)
|
|
59
|
+
if self._cursor_index >= total:
|
|
60
|
+
self._cursor_index = max(0, total - 1)
|
|
61
|
+
self._refresh_content()
|
|
62
|
+
self._scroll_to_cursor()
|
|
63
|
+
|
|
64
|
+
def set_on_toggle(self, callback: Callable[[FileStatus], None]) -> None:
|
|
65
|
+
"""Register a callback invoked when the user toggles a file's staged state."""
|
|
66
|
+
self._on_toggle = callback
|
|
67
|
+
|
|
68
|
+
def set_on_selection_change(self, callback: Callable[[], None]) -> None:
|
|
69
|
+
"""Register a callback invoked when the cursor moves to a different file."""
|
|
70
|
+
self._on_selection_change = callback
|
|
71
|
+
|
|
72
|
+
def set_on_stage_all(self, callback: Callable[[], None]) -> None:
|
|
73
|
+
"""Register a callback invoked when the user triggers stage-all."""
|
|
74
|
+
self._on_stage_all = callback
|
|
75
|
+
|
|
76
|
+
def set_on_unstage_all(self, callback: Callable[[], None]) -> None:
|
|
77
|
+
"""Register a callback invoked when the user triggers unstage-all."""
|
|
78
|
+
self._on_unstage_all = callback
|
|
79
|
+
|
|
80
|
+
def action_cursor_down(self) -> None:
|
|
81
|
+
"""Move cursor down to the next file in the flat list."""
|
|
82
|
+
all_files = self._all_files_flat
|
|
83
|
+
if self._cursor_index < len(all_files) - 1:
|
|
84
|
+
self._cursor_index += 1
|
|
85
|
+
self._refresh_content()
|
|
86
|
+
self._scroll_to_cursor()
|
|
87
|
+
if self._on_selection_change:
|
|
88
|
+
self._on_selection_change()
|
|
89
|
+
|
|
90
|
+
def action_cursor_up(self) -> None:
|
|
91
|
+
"""Move cursor up to the previous file in the flat list."""
|
|
92
|
+
if self._cursor_index > 0:
|
|
93
|
+
self._cursor_index -= 1
|
|
94
|
+
self._refresh_content()
|
|
95
|
+
self._scroll_to_cursor()
|
|
96
|
+
if self._on_selection_change:
|
|
97
|
+
self._on_selection_change()
|
|
98
|
+
|
|
99
|
+
def action_toggle_file(self) -> None:
|
|
100
|
+
"""Stage or unstage the file under the cursor."""
|
|
101
|
+
file = self.selected_file
|
|
102
|
+
if file and self._on_toggle:
|
|
103
|
+
self._on_toggle(file)
|
|
104
|
+
|
|
105
|
+
def action_stage_all(self) -> None:
|
|
106
|
+
"""Stage all currently unstaged files."""
|
|
107
|
+
if self._on_stage_all:
|
|
108
|
+
self._on_stage_all()
|
|
109
|
+
|
|
110
|
+
def action_unstage_all(self) -> None:
|
|
111
|
+
"""Unstage all currently staged files."""
|
|
112
|
+
if self._on_unstage_all:
|
|
113
|
+
self._on_unstage_all()
|
|
114
|
+
|
|
115
|
+
def _refresh_content(self) -> None:
|
|
116
|
+
"""Clear the widget and re-render the full file list."""
|
|
117
|
+
_ = self.clear()
|
|
118
|
+
_ = self.write(self._render_files())
|
|
119
|
+
|
|
120
|
+
def _render_files(self) -> Text:
|
|
121
|
+
"""Build the full layout with STAGED and UNSTAGED sections."""
|
|
122
|
+
text = Text(no_wrap=True)
|
|
123
|
+
staged = [f for f in self._files if f.is_staged]
|
|
124
|
+
unstaged = [f for f in self._files if f.is_unstaged]
|
|
125
|
+
|
|
126
|
+
text.append(" \u25a0 STAGED", style="bold #c0c1ff")
|
|
127
|
+
text.append("\n")
|
|
128
|
+
|
|
129
|
+
if staged:
|
|
130
|
+
text.append(self._build_file_entries(staged, 0))
|
|
131
|
+
else:
|
|
132
|
+
text.append(" No changed files\n", style="italic #c7c4d7")
|
|
133
|
+
|
|
134
|
+
text.append(" \u25a0 UNSTAGED", style="bold #ffb783")
|
|
135
|
+
text.append("\n")
|
|
136
|
+
|
|
137
|
+
if unstaged:
|
|
138
|
+
text.append(self._build_file_entries(unstaged, len(staged)))
|
|
139
|
+
else:
|
|
140
|
+
text.append(" No changed files\n", style="italic #c7c4d7")
|
|
141
|
+
|
|
142
|
+
return text
|
|
143
|
+
|
|
144
|
+
def _build_file_entries(self, files: list[FileStatus], start_index: int) -> Text:
|
|
145
|
+
"""Build rich text entries for a slice of files, marking the cursor row."""
|
|
146
|
+
text = Text()
|
|
147
|
+
for i, f in enumerate(files):
|
|
148
|
+
linear_idx = start_index + i
|
|
149
|
+
is_cursor = linear_idx == self._cursor_index
|
|
150
|
+
status = f.display_status
|
|
151
|
+
color = _STATUS_COLORS.get(status, "#e4e1ed")
|
|
152
|
+
|
|
153
|
+
if is_cursor:
|
|
154
|
+
text.append(" \u276f", style="bold #c0c1ff")
|
|
155
|
+
else:
|
|
156
|
+
text.append(" ")
|
|
157
|
+
|
|
158
|
+
text.append(f" {status}", style=f"bold {color}")
|
|
159
|
+
text.append(f" {self._truncate_path(f.display_path)}", style="#e4e1ed")
|
|
160
|
+
text.append("\n")
|
|
161
|
+
|
|
162
|
+
return text
|
|
163
|
+
|
|
164
|
+
def _truncate_path(self, display_path: str) -> str:
|
|
165
|
+
"""Truncate a rendered path to ``_MAX_PATH_CHARS`` with a trailing ``..``."""
|
|
166
|
+
if len(display_path) > _MAX_PATH_CHARS:
|
|
167
|
+
return display_path[:_MAX_PATH_CHARS - 2] + ".."
|
|
168
|
+
return display_path
|
|
169
|
+
|
|
170
|
+
def _row_for_index(self, index: int) -> int:
|
|
171
|
+
"""Rendered-line offset of flat cursor ``index`` (accounts for section headers)."""
|
|
172
|
+
all_files = self._all_files_flat
|
|
173
|
+
if not all_files:
|
|
174
|
+
return 0
|
|
175
|
+
staged_count = len([f for f in self._files if f.is_staged])
|
|
176
|
+
if index < staged_count:
|
|
177
|
+
return 1 + index
|
|
178
|
+
if staged_count > 0:
|
|
179
|
+
return index + 2
|
|
180
|
+
return index + 3
|
|
181
|
+
|
|
182
|
+
def _scroll_to_cursor(self) -> None:
|
|
183
|
+
"""Ensure the cursor row is visible; no-op when the file list is empty."""
|
|
184
|
+
if not self._all_files_flat:
|
|
185
|
+
return
|
|
186
|
+
target = 0 if self._cursor_index == 0 else self._row_for_index(self._cursor_index)
|
|
187
|
+
self.scroll_to(y=target, animate=False)
|