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.
@@ -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