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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Commit log widget — renders ``git log --oneline --graph --decorate`` output."""
|
|
2
|
+
|
|
3
|
+
from typing import Self, override
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.widgets import RichLog
|
|
7
|
+
|
|
8
|
+
_GRAPH_CHARS: str = "*|/\\ -_`"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def extract_commit_hash(line: str) -> str | None:
|
|
12
|
+
"""First hex token after the graph prefix, or None for graph-only lines."""
|
|
13
|
+
for i, ch in enumerate(line):
|
|
14
|
+
if ch not in _GRAPH_CHARS:
|
|
15
|
+
line = line[i:]
|
|
16
|
+
break
|
|
17
|
+
token = line.split(" ", 1)[0] if line else ""
|
|
18
|
+
if len(token) >= 7 and all(c in "0123456789abcdef" for c in token):
|
|
19
|
+
return token
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CommitLogWidget(RichLog):
|
|
24
|
+
"""Displays the git commit graph with ASCII art, refs, and cursor navigation.
|
|
25
|
+
|
|
26
|
+
Parses the output of ``git log --all --oneline --graph --decorate`` and renders:
|
|
27
|
+
- Graph lines (``|``, ``/``, ``\\``) in secondary colours
|
|
28
|
+
- Commit nodes (``*``) in primary colour
|
|
29
|
+
- Branch/tag refs (``(...)``) in accent colour
|
|
30
|
+
- Commit hashes and messages in default text
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
can_focus = False
|
|
34
|
+
|
|
35
|
+
def __init__(self, **kwargs) -> None:
|
|
36
|
+
super().__init__(
|
|
37
|
+
highlight=False, markup=False, auto_scroll=False, max_lines=None, **kwargs
|
|
38
|
+
)
|
|
39
|
+
self._log_lines: list[str] = []
|
|
40
|
+
self._cursor_index: int = 0
|
|
41
|
+
self._detail_hash: str | None = None
|
|
42
|
+
self._detail_text: str | None = None
|
|
43
|
+
_ = self.write(Text(" (loading...)", style="italic #c7c4d7"))
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_detail_mode(self) -> bool:
|
|
47
|
+
"""True while a commit detail view is shown instead of the log."""
|
|
48
|
+
return self._detail_hash is not None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def cursor_line(self) -> str | None:
|
|
52
|
+
"""The raw graph line under the cursor, or None when the log is empty."""
|
|
53
|
+
if 0 <= self._cursor_index < len(self._log_lines):
|
|
54
|
+
return self._log_lines[self._cursor_index]
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def show_details(self, commit_hash: str, text: str) -> None:
|
|
58
|
+
"""Enter detail mode: render `text` (raw) and scroll to top."""
|
|
59
|
+
self._detail_hash = commit_hash
|
|
60
|
+
self._detail_text = text
|
|
61
|
+
self._refresh_content()
|
|
62
|
+
_ = self.scroll_home(animate=False)
|
|
63
|
+
|
|
64
|
+
def exit_details(self) -> None:
|
|
65
|
+
"""Leave detail mode and restore the log at the cursor."""
|
|
66
|
+
self._detail_hash = None
|
|
67
|
+
self._detail_text = None
|
|
68
|
+
self._refresh_content()
|
|
69
|
+
self._scroll_to_cursor()
|
|
70
|
+
|
|
71
|
+
def show_log(self, log_text: str) -> None:
|
|
72
|
+
"""Set the log text from raw git output, resetting cursor if needed."""
|
|
73
|
+
self._detail_hash = None
|
|
74
|
+
self._detail_text = None
|
|
75
|
+
if not log_text:
|
|
76
|
+
_ = self.clear()
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
self._log_lines = log_text.splitlines()
|
|
80
|
+
if self._cursor_index >= len(self._log_lines):
|
|
81
|
+
self._cursor_index = max(0, len(self._log_lines) - 1)
|
|
82
|
+
self._refresh_content()
|
|
83
|
+
|
|
84
|
+
@override
|
|
85
|
+
def clear(self) -> Self:
|
|
86
|
+
"""Clear the log and show an empty-state placeholder."""
|
|
87
|
+
self._detail_hash = None
|
|
88
|
+
self._detail_text = None
|
|
89
|
+
self._log_lines = []
|
|
90
|
+
self._cursor_index = 0
|
|
91
|
+
_ = super().clear()
|
|
92
|
+
_ = self.write(Text(" No commits yet", style="italic #c7c4d7"))
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def action_cursor_down(self) -> None:
|
|
96
|
+
"""Move the cursor down one commit in the log."""
|
|
97
|
+
if self.is_detail_mode:
|
|
98
|
+
return
|
|
99
|
+
if self._cursor_index < len(self._log_lines) - 1:
|
|
100
|
+
self._cursor_index += 1
|
|
101
|
+
self._refresh_content()
|
|
102
|
+
self._scroll_to_cursor()
|
|
103
|
+
|
|
104
|
+
def action_cursor_up(self) -> None:
|
|
105
|
+
"""Move the cursor up one commit in the log."""
|
|
106
|
+
if self.is_detail_mode:
|
|
107
|
+
return
|
|
108
|
+
if self._cursor_index > 0:
|
|
109
|
+
self._cursor_index -= 1
|
|
110
|
+
self._refresh_content()
|
|
111
|
+
self._scroll_to_cursor()
|
|
112
|
+
|
|
113
|
+
def _scroll_to_cursor(self) -> None:
|
|
114
|
+
"""Ensure the cursor line is visible in the viewport."""
|
|
115
|
+
_ = self.scroll_to(y=self._cursor_index, animate=False)
|
|
116
|
+
|
|
117
|
+
def _refresh_content(self) -> None:
|
|
118
|
+
"""Re-render either the detail view or the graph log."""
|
|
119
|
+
if self._detail_text is not None:
|
|
120
|
+
_ = super().clear()
|
|
121
|
+
_ = self.write(self._detail_text)
|
|
122
|
+
return
|
|
123
|
+
text = Text()
|
|
124
|
+
for i, line in enumerate(self._log_lines):
|
|
125
|
+
if not line:
|
|
126
|
+
text.append("\n")
|
|
127
|
+
continue
|
|
128
|
+
rendered = self._render_log_line(line, cursor=i == self._cursor_index)
|
|
129
|
+
text.append(rendered)
|
|
130
|
+
text.append("\n")
|
|
131
|
+
_ = super().clear()
|
|
132
|
+
_ = self.write(text)
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _render_log_line(line: str, cursor: bool = False) -> Text:
|
|
136
|
+
"""Render one raw graph line with graph/ref/message coloring."""
|
|
137
|
+
result = Text()
|
|
138
|
+
|
|
139
|
+
if cursor:
|
|
140
|
+
result.append(" \u276f", style="bold #c0c1ff")
|
|
141
|
+
else:
|
|
142
|
+
result.append(" ")
|
|
143
|
+
|
|
144
|
+
graph_part = ""
|
|
145
|
+
content_part = line
|
|
146
|
+
|
|
147
|
+
for i, ch in enumerate(line):
|
|
148
|
+
if ch not in "*|/\\ -_`":
|
|
149
|
+
graph_part = line[:i]
|
|
150
|
+
content_part = line[i:]
|
|
151
|
+
break
|
|
152
|
+
else:
|
|
153
|
+
result.append(line, style="#908fa0")
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
for ch in graph_part:
|
|
157
|
+
if ch == "*":
|
|
158
|
+
result.append("*", style="bold #c0c1ff")
|
|
159
|
+
elif ch in "|/\\":
|
|
160
|
+
result.append(ch, style="#908fa0")
|
|
161
|
+
else:
|
|
162
|
+
result.append(ch, style="#c7c4d7")
|
|
163
|
+
|
|
164
|
+
content = content_part.strip()
|
|
165
|
+
if not content:
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
parts = content.split(" ", 1)
|
|
169
|
+
# Skip the hash (parts[0]), only show the rest (message + refs)
|
|
170
|
+
rest = parts[1] if len(parts) > 1 else ""
|
|
171
|
+
|
|
172
|
+
if rest:
|
|
173
|
+
rest_text = Text()
|
|
174
|
+
i = 0
|
|
175
|
+
while i < len(rest):
|
|
176
|
+
if rest[i] == "(":
|
|
177
|
+
paren_depth = 0
|
|
178
|
+
paren_start = i
|
|
179
|
+
while i < len(rest):
|
|
180
|
+
if rest[i] == "(":
|
|
181
|
+
paren_depth += 1
|
|
182
|
+
elif rest[i] == ")":
|
|
183
|
+
paren_depth -= 1
|
|
184
|
+
if paren_depth == 0:
|
|
185
|
+
i += 1
|
|
186
|
+
break
|
|
187
|
+
i += 1
|
|
188
|
+
refs_content = rest[paren_start:i]
|
|
189
|
+
rest_text.append(refs_content, style="bold #ffb783")
|
|
190
|
+
else:
|
|
191
|
+
rest_text.append(rest[i], style="#e4e1ed")
|
|
192
|
+
i += 1
|
|
193
|
+
|
|
194
|
+
result.append(" ")
|
|
195
|
+
result.append(rest_text)
|
|
196
|
+
|
|
197
|
+
return result
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Commit screen modal — centered overlay for writing commit messages."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import override
|
|
5
|
+
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import Horizontal, Vertical
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Button, Input, Static
|
|
11
|
+
|
|
12
|
+
from gitux.presenter.commit_presenter import CommitPresenter
|
|
13
|
+
|
|
14
|
+
_COMMIT_TYPES: tuple[str, ...] = (
|
|
15
|
+
"feat", "fix", "chore", "docs", "style", "refactor", "perf", "test",
|
|
16
|
+
"build", "ci", "other",
|
|
17
|
+
)
|
|
18
|
+
_PREFIX_RE: re.Pattern[str] = re.compile(r"^([a-z]+):\s*(.*)$", re.DOTALL)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CommitScreen(ModalScreen[str]):
|
|
22
|
+
"""Modal screen for entering a commit message.
|
|
23
|
+
|
|
24
|
+
Opens centered when pressing 'c'.
|
|
25
|
+
Returns the commit hash on success, or None if cancelled/failed.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
BINDINGS: list[Binding] = [
|
|
29
|
+
Binding("escape", "cancel", "Cancel"),
|
|
30
|
+
Binding("enter", "submit", "Commit"),
|
|
31
|
+
Binding("tab", "cycle_type", "Next type", show=False),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
def __init__(self, presenter: CommitPresenter) -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self.presenter: CommitPresenter = presenter
|
|
37
|
+
self._commit_type_index: int = 0
|
|
38
|
+
|
|
39
|
+
@override
|
|
40
|
+
def compose(self) -> ComposeResult:
|
|
41
|
+
"""Build the modal: title, type row, message input, and buttons."""
|
|
42
|
+
with Vertical(id="commit-modal"):
|
|
43
|
+
yield Static("Commit Changes", id="commit-modal-title")
|
|
44
|
+
yield Static(f"type: {_COMMIT_TYPES[0]}", id="commit-modal-type")
|
|
45
|
+
yield Input(
|
|
46
|
+
placeholder="Commit message...",
|
|
47
|
+
id="commit-modal-input",
|
|
48
|
+
)
|
|
49
|
+
with Horizontal(id="commit-modal-buttons"):
|
|
50
|
+
yield Button("Commit (Enter)", id="commit-modal-btn-commit")
|
|
51
|
+
yield Button("Cancel (Esc)", id="commit-modal-btn-cancel")
|
|
52
|
+
|
|
53
|
+
def on_mount(self) -> None:
|
|
54
|
+
"""Focus the commit input immediately."""
|
|
55
|
+
_ = self.query_one("#commit-modal-input", Input).focus()
|
|
56
|
+
|
|
57
|
+
def action_cancel(self) -> None:
|
|
58
|
+
"""Dismiss the modal without committing."""
|
|
59
|
+
_ = self.dismiss(None)
|
|
60
|
+
|
|
61
|
+
def action_submit(self) -> None:
|
|
62
|
+
"""Commit the current message."""
|
|
63
|
+
self._do_commit()
|
|
64
|
+
|
|
65
|
+
def action_cycle_type(self) -> None:
|
|
66
|
+
"""Advance the type forward only; wraps from 'other' back to 'feat'."""
|
|
67
|
+
self._commit_type_index = (self._commit_type_index + 1) % len(_COMMIT_TYPES)
|
|
68
|
+
_ = self.query_one("#commit-modal-type", Static).update(
|
|
69
|
+
f"type: {_COMMIT_TYPES[self._commit_type_index]}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
73
|
+
"""Commit or cancel based on the pressed button."""
|
|
74
|
+
if event.button.id == "commit-modal-btn-commit":
|
|
75
|
+
self._do_commit()
|
|
76
|
+
elif event.button.id == "commit-modal-btn-cancel":
|
|
77
|
+
self.action_cancel()
|
|
78
|
+
|
|
79
|
+
def on_input_submitted(self, _event: Input.Submitted) -> None:
|
|
80
|
+
"""Submit the commit when Enter is pressed in the message input."""
|
|
81
|
+
self._do_commit()
|
|
82
|
+
|
|
83
|
+
def _do_commit(self) -> None:
|
|
84
|
+
"""Validate the message and run the commit, dismissing on success."""
|
|
85
|
+
commit_input = self.query_one("#commit-modal-input", Input)
|
|
86
|
+
message = commit_input.value.strip()
|
|
87
|
+
|
|
88
|
+
if not message:
|
|
89
|
+
_ = self.notify("Commit message cannot be empty", severity="error")
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
commit_type = _COMMIT_TYPES[self._commit_type_index]
|
|
93
|
+
message = self._strip_type_prefix(message)
|
|
94
|
+
if commit_type != "other":
|
|
95
|
+
message = f"{commit_type}: {message}"
|
|
96
|
+
|
|
97
|
+
result = self.presenter.commit(message)
|
|
98
|
+
|
|
99
|
+
if result.success:
|
|
100
|
+
_ = self.dismiss(result.commit_hash)
|
|
101
|
+
else:
|
|
102
|
+
_ = self.notify(result.error or "Commit failed", severity="error")
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _strip_type_prefix(message: str) -> str:
|
|
106
|
+
"""Remove a leading '<known-type>:' so selecting that type does not duplicate it."""
|
|
107
|
+
match = _PREFIX_RE.match(message)
|
|
108
|
+
if match and match.group(1) in _COMMIT_TYPES:
|
|
109
|
+
return match.group(2)
|
|
110
|
+
return message
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Diff viewer widget — shows unified diffs with line numbers."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Self, override
|
|
5
|
+
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual.widgets import RichLog
|
|
8
|
+
|
|
9
|
+
_MAX_DIFF_LINES: int = 500
|
|
10
|
+
|
|
11
|
+
_COLORS: dict[str, str] = {
|
|
12
|
+
"addition": "#10b981",
|
|
13
|
+
"deletion": "#ffb4ab",
|
|
14
|
+
"header": "#c0c1ff",
|
|
15
|
+
"default": "#e4e1ed",
|
|
16
|
+
"dim": "#c7c4d7",
|
|
17
|
+
"line_num": "#464554",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_DIFF_METADATA_PREFIXES: tuple[str, ...] = (
|
|
21
|
+
"diff --",
|
|
22
|
+
"index ",
|
|
23
|
+
"--- ",
|
|
24
|
+
"+++ ",
|
|
25
|
+
"new file mode ",
|
|
26
|
+
"deleted file mode ",
|
|
27
|
+
"old mode ",
|
|
28
|
+
"new mode ",
|
|
29
|
+
"copy from ",
|
|
30
|
+
"copy to ",
|
|
31
|
+
"rename from ",
|
|
32
|
+
"rename to ",
|
|
33
|
+
"similarity index ",
|
|
34
|
+
"dissimilarity index ",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_diff_metadata(line: str) -> bool:
|
|
39
|
+
"""Return True if ``line`` is git diff metadata (``diff --``, ``index``, ...)."""
|
|
40
|
+
return line.startswith(_DIFF_METADATA_PREFIXES)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_hunk_header(line: str) -> tuple[int, int]:
|
|
44
|
+
"""Parse @@ -old_start,old_count +new_start,new_count @@.
|
|
45
|
+
|
|
46
|
+
Returns (old_start, new_start).
|
|
47
|
+
"""
|
|
48
|
+
match = re.search(r"\-(\d+)", line)
|
|
49
|
+
old_start = int(match.group(1)) if match else 0
|
|
50
|
+
match = re.search(r"\+(\d+)", line)
|
|
51
|
+
new_start = int(match.group(1)) if match else 0
|
|
52
|
+
return old_start, new_start
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DiffViewerWidget(RichLog):
|
|
56
|
+
"""Displays unified diffs with line numbers and semantic coloring.
|
|
57
|
+
|
|
58
|
+
Shows:
|
|
59
|
+
- Hunk headers (@@ ... @@) in primary color
|
|
60
|
+
- Added lines (+) in green with new file line number
|
|
61
|
+
- Removed lines (-) in red with old file line number
|
|
62
|
+
- Context lines in default with both line numbers
|
|
63
|
+
- Git metadata headers are hidden (redundant)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
can_focus = False
|
|
67
|
+
|
|
68
|
+
def __init__(self, **kwargs) -> None:
|
|
69
|
+
super().__init__(
|
|
70
|
+
highlight=False, markup=False, auto_scroll=False, max_lines=None, **kwargs
|
|
71
|
+
)
|
|
72
|
+
self._current_diff: str = ""
|
|
73
|
+
|
|
74
|
+
def show_diff(self, diff_text: str) -> None:
|
|
75
|
+
"""Display a unified diff with line numbers and truncation."""
|
|
76
|
+
self._current_diff = diff_text
|
|
77
|
+
if not diff_text:
|
|
78
|
+
self._show_placeholder("Select a file to view diff")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
lines = diff_text.splitlines()
|
|
82
|
+
total = len(lines)
|
|
83
|
+
|
|
84
|
+
if total > _MAX_DIFF_LINES:
|
|
85
|
+
lines = lines[:_MAX_DIFF_LINES]
|
|
86
|
+
truncated = True
|
|
87
|
+
else:
|
|
88
|
+
truncated = False
|
|
89
|
+
|
|
90
|
+
text = Text()
|
|
91
|
+
old_lineno = 0
|
|
92
|
+
new_lineno = 0
|
|
93
|
+
|
|
94
|
+
for line in lines:
|
|
95
|
+
if line.startswith("@@"):
|
|
96
|
+
old_lineno, new_lineno = _parse_hunk_header(line)
|
|
97
|
+
text.append(f" {line}\n", style=_COLORS["header"])
|
|
98
|
+
elif line.startswith("+"):
|
|
99
|
+
content = line[1:] # strip leading +
|
|
100
|
+
text.append(
|
|
101
|
+
f" {new_lineno:>4} +{content}\n",
|
|
102
|
+
style=_COLORS["addition"],
|
|
103
|
+
)
|
|
104
|
+
new_lineno += 1
|
|
105
|
+
elif line.startswith("-"):
|
|
106
|
+
content = line[1:] # strip leading -
|
|
107
|
+
text.append(
|
|
108
|
+
f" {old_lineno:>4} -{content}\n",
|
|
109
|
+
style=_COLORS["deletion"],
|
|
110
|
+
)
|
|
111
|
+
old_lineno += 1
|
|
112
|
+
elif _is_diff_metadata(line):
|
|
113
|
+
continue # skip metadata headers
|
|
114
|
+
else:
|
|
115
|
+
# Context line (starts with space)
|
|
116
|
+
content = line[1:] if line.startswith(" ") else line
|
|
117
|
+
text.append(
|
|
118
|
+
f" {new_lineno:>4} {content}\n",
|
|
119
|
+
style=_COLORS["default"],
|
|
120
|
+
)
|
|
121
|
+
old_lineno += 1
|
|
122
|
+
new_lineno += 1
|
|
123
|
+
|
|
124
|
+
if truncated:
|
|
125
|
+
text.append(
|
|
126
|
+
f"\n [{_MAX_DIFF_LINES} of {total} lines shown]",
|
|
127
|
+
style=f"italic {_COLORS['dim']}",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
_ = super().clear()
|
|
131
|
+
_ = self.write(text)
|
|
132
|
+
_ = self.scroll_home(animate=False)
|
|
133
|
+
|
|
134
|
+
def show_binary_placeholder(self) -> None:
|
|
135
|
+
"""Show placeholder for binary files."""
|
|
136
|
+
self._show_placeholder("Binary file, no diff available")
|
|
137
|
+
|
|
138
|
+
@override
|
|
139
|
+
def clear(self) -> Self:
|
|
140
|
+
"""Clear the diff display."""
|
|
141
|
+
self._current_diff = ""
|
|
142
|
+
_ = self._show_placeholder("Select a file to view diff")
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
def _show_placeholder(self, message: str) -> None:
|
|
146
|
+
"""Show a dim placeholder message and scroll to top."""
|
|
147
|
+
_ = super().clear()
|
|
148
|
+
_ = self.write(Text(f" {message}", style=f"italic {_COLORS['dim']}"))
|
|
149
|
+
_ = self.scroll_home(animate=False)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Application footer — shows version, keyboard shortcuts, encoding, and sync status."""
|
|
2
|
+
|
|
3
|
+
from rich.text import Text
|
|
4
|
+
from textual.widgets import Static
|
|
5
|
+
|
|
6
|
+
from gitux import __version__
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GituxFooter(Static):
|
|
10
|
+
"""Bottom status bar with version info, keybinding hints, and sync indicator.
|
|
11
|
+
|
|
12
|
+
Layout:
|
|
13
|
+
- Version tag (e.g. ``GITUX v1.0.4``)
|
|
14
|
+
- List of common keyboard shortcuts
|
|
15
|
+
- File encoding
|
|
16
|
+
- Sync status icon (✓ / ✗)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, **kwargs) -> None:
|
|
20
|
+
super().__init__("", **kwargs)
|
|
21
|
+
self._sync_ok: bool = True
|
|
22
|
+
self._encoding: str = "UTF-8"
|
|
23
|
+
|
|
24
|
+
def on_mount(self) -> None:
|
|
25
|
+
"""Render the footer on initial mount."""
|
|
26
|
+
self._refresh()
|
|
27
|
+
|
|
28
|
+
def update_sync(self, ok: bool = True, encoding: str = "UTF-8") -> None:
|
|
29
|
+
"""Update the sync indicator and encoding display."""
|
|
30
|
+
self._sync_ok = ok
|
|
31
|
+
self._encoding = encoding
|
|
32
|
+
self._refresh()
|
|
33
|
+
|
|
34
|
+
def _refresh(self) -> None:
|
|
35
|
+
"""Re-render the footer content from the current state."""
|
|
36
|
+
t = Text()
|
|
37
|
+
|
|
38
|
+
t.append(" \u25a0", style="bold #ffb783")
|
|
39
|
+
t.append(f" GITUX v{__version__} \u2502", style="bold #c7c4d7")
|
|
40
|
+
t.append(" [C] Commit [P] Push [R] Refresh [S] Stage [A] Stage All [B] Branches [?] Help [Q] Quit \u2502", style="#b7c8e1")
|
|
41
|
+
t.append(f" {self._encoding} ", style="#c7c4d7")
|
|
42
|
+
t.append("\u2713" if self._sync_ok else "\u2717", style="bold #10b981" if self._sync_ok else "bold #ffb4ab")
|
|
43
|
+
|
|
44
|
+
self.update(t)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Top application bar — displays repo name, owner, branch badge, and WIP state."""
|
|
2
|
+
|
|
3
|
+
from rich.text import Text
|
|
4
|
+
from textual.widgets import Static
|
|
5
|
+
|
|
6
|
+
from gitux.domain import WipState
|
|
7
|
+
|
|
8
|
+
_MAX_OWNER_CHARS: int = 24
|
|
9
|
+
_MAX_REPO_CHARS: int = 32
|
|
10
|
+
_MAX_BRANCH_CHARS: int = 24
|
|
11
|
+
|
|
12
|
+
_WIP_DOT_STYLE: dict[WipState, str] = {
|
|
13
|
+
WipState.CLEAN: "#10b981", # green
|
|
14
|
+
WipState.DIRTY: "#ffb783", # yellow
|
|
15
|
+
WipState.CONFLICT: "#ffb4ab", # red
|
|
16
|
+
WipState.UNKNOWN: "#908fa0", # gray
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TopAppBar(Static):
|
|
21
|
+
"""Header bar showing repository identity and current branch.
|
|
22
|
+
|
|
23
|
+
Renders a compact one-line header with:
|
|
24
|
+
- Circled dot icon + "GITUX //" prefix
|
|
25
|
+
- Owner/repo name
|
|
26
|
+
- Branch name in a styled badge (with ``*`` on the default branch), or
|
|
27
|
+
detached-HEAD warning in red
|
|
28
|
+
- Work-in-progress dot
|
|
29
|
+
- Gear icon suffix
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, **kwargs) -> None:
|
|
33
|
+
super().__init__("", **kwargs)
|
|
34
|
+
self._repo_name: str = ""
|
|
35
|
+
self._owner: str = ""
|
|
36
|
+
self._branch: str = ""
|
|
37
|
+
self._is_detached: bool = False
|
|
38
|
+
self._default_branch: str = ""
|
|
39
|
+
self._wip_state: WipState = WipState.UNKNOWN
|
|
40
|
+
|
|
41
|
+
def update_display(
|
|
42
|
+
self,
|
|
43
|
+
repo_name: str = "",
|
|
44
|
+
owner: str = "",
|
|
45
|
+
branch: str = "",
|
|
46
|
+
is_detached: bool = False,
|
|
47
|
+
default_branch: str = "",
|
|
48
|
+
wip_state: WipState = WipState.UNKNOWN,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Update the header with current repo and branch information."""
|
|
51
|
+
self._repo_name = repo_name or "..."
|
|
52
|
+
self._owner = owner
|
|
53
|
+
self._branch = branch
|
|
54
|
+
self._is_detached = is_detached
|
|
55
|
+
self._default_branch = default_branch
|
|
56
|
+
self._wip_state = wip_state
|
|
57
|
+
self.update(self._render_text())
|
|
58
|
+
|
|
59
|
+
def _render_text(self) -> Text:
|
|
60
|
+
"""Render the header line as rich text."""
|
|
61
|
+
text = Text()
|
|
62
|
+
text.append(" \u25c9", style="bold #c0c1ff")
|
|
63
|
+
text.append(" GITUX // ", style="#e4e1ed")
|
|
64
|
+
repo = self._repo_name[:_MAX_REPO_CHARS]
|
|
65
|
+
if self._owner:
|
|
66
|
+
text.append(
|
|
67
|
+
f"{self._owner[:_MAX_OWNER_CHARS]}/{repo}", style="#e4e1ed"
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
text.append(repo, style="#e4e1ed")
|
|
71
|
+
if self._branch:
|
|
72
|
+
branch = self._branch[:_MAX_BRANCH_CHARS]
|
|
73
|
+
star = "*" if self._branch == self._default_branch else ""
|
|
74
|
+
if self._is_detached:
|
|
75
|
+
text.append(f" ({branch})", style="#ffb4ab on #93000a")
|
|
76
|
+
else:
|
|
77
|
+
text.append(f" [{branch}{star}]", style="#1000a9 on #c0c1ff")
|
|
78
|
+
text.append(" \u25cf", style=_WIP_DOT_STYLE[self._wip_state])
|
|
79
|
+
text.append(" ", style="")
|
|
80
|
+
text.append("\u2699", style="#c7c4d7")
|
|
81
|
+
return text
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Help screen modal — shows all keyboard shortcuts."""
|
|
2
|
+
|
|
3
|
+
from rich.text import Text
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.binding import Binding
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HelpScreen(ModalScreen[None]):
|
|
12
|
+
"""Modal screen displaying all keyboard shortcuts."""
|
|
13
|
+
|
|
14
|
+
BINDINGS = [
|
|
15
|
+
Binding("escape", "dismiss", "Close"),
|
|
16
|
+
Binding("q", "dismiss", "Close"),
|
|
17
|
+
Binding("?", "dismiss", "Close"),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
def compose(self) -> ComposeResult:
|
|
21
|
+
"""Build the modal: title, help body, and close hint."""
|
|
22
|
+
with Vertical(id="help-container"):
|
|
23
|
+
yield Static("GITUX — Keyboard Shortcuts", id="help-title")
|
|
24
|
+
yield Static(self._build_help_text(), id="help-body")
|
|
25
|
+
yield Static("Press ? or Esc to close", id="help-close-hint")
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def _build_help_text() -> Text:
|
|
29
|
+
"""Build the keyboard-shortcut reference as rich text."""
|
|
30
|
+
t = Text()
|
|
31
|
+
|
|
32
|
+
def _section(title: str) -> None:
|
|
33
|
+
"""Append a section header line."""
|
|
34
|
+
t.append(f"\n {title}\n", style="bold #6366f1")
|
|
35
|
+
|
|
36
|
+
def _shortcut(key: str, desc: str) -> None:
|
|
37
|
+
"""Append a shortcut row line."""
|
|
38
|
+
t.append(f" {key:<14}", style="bold #f59e0b")
|
|
39
|
+
t.append(f"{desc}\n", style="#e2e8f0")
|
|
40
|
+
|
|
41
|
+
# ── Navigation ──
|
|
42
|
+
_section("Navigation")
|
|
43
|
+
_shortcut("Tab", "Next section (Files/Commits/Diff)")
|
|
44
|
+
_shortcut("\u2191 / \u2193", "Scroll active section content")
|
|
45
|
+
_shortcut("Enter", "Activate selected section")
|
|
46
|
+
_shortcut("j / k", "Scroll down / up")
|
|
47
|
+
|
|
48
|
+
# ── File Actions ──
|
|
49
|
+
_section("Files")
|
|
50
|
+
_shortcut("s / Enter", "Stage / Unstage file")
|
|
51
|
+
_shortcut("a", "Stage all files")
|
|
52
|
+
_shortcut("A", "Unstage all files")
|
|
53
|
+
|
|
54
|
+
# ── Commit & Push ──
|
|
55
|
+
_section("Commit & Push")
|
|
56
|
+
_shortcut("c", "Focus commit message")
|
|
57
|
+
_shortcut("Ctrl+Enter", "Create commit")
|
|
58
|
+
_shortcut("Ctrl+p", "Push to remote")
|
|
59
|
+
|
|
60
|
+
# ── General ──
|
|
61
|
+
_section("General")
|
|
62
|
+
_shortcut("r", "Refresh status")
|
|
63
|
+
_shortcut("b", "Branches panel")
|
|
64
|
+
_shortcut("?", "Show this help")
|
|
65
|
+
_shortcut("q", "Quit GITUX")
|
|
66
|
+
_shortcut("Escape", "Cancel / Go back")
|
|
67
|
+
|
|
68
|
+
# ── Upcoming (dimmed) ──
|
|
69
|
+
_section("Coming Soon")
|
|
70
|
+
t.append(" / Search files\n", style="dim #64748b")
|
|
71
|
+
t.append(" L Commit log\n", style="dim #64748b")
|
|
72
|
+
t.append(" S Stash panel\n", style="dim #64748b")
|
|
73
|
+
|
|
74
|
+
return t
|