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/ui/app.py ADDED
@@ -0,0 +1,452 @@
1
+ """Main application — bento-grid TUI for Git operations.
2
+
3
+ Composes the full UI layout, manages block activation (files/commits/diff),
4
+ routes keyboard events, and orchestrates data flow between presenters and widgets.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ from textual import work
10
+ from textual.app import App, ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.containers import Horizontal, Vertical
13
+ from textual.events import Key
14
+
15
+ from gitux.domain import (
16
+ FileCounts,
17
+ FileStatus,
18
+ HeadSummary,
19
+ OperationState,
20
+ PushResult,
21
+ RemoteStatus,
22
+ RepoInfo,
23
+ derive_wip_state,
24
+ )
25
+ from gitux.presenter.commit_presenter import CommitPresenter
26
+ from gitux.presenter.repo_presenter import RepoPresenter
27
+ from gitux.ui.widgets import (
28
+ BlockContainer,
29
+ BranchScreen,
30
+ ChangedFilesPanel,
31
+ CommitLogWidget,
32
+ CommitScreen,
33
+ DiffViewerWidget,
34
+ GituxFooter,
35
+ HelpScreen,
36
+ RepoStatsBar,
37
+ TopAppBar,
38
+ extract_commit_hash,
39
+ )
40
+
41
+ _CSS_PATH: str = str(Path(__file__).parent / "gitux.tcss")
42
+
43
+ _BLOCK_MAP: dict[str, dict[str, str]] = {
44
+ "files": {"block": "#files-block", "content": "#changed-files"},
45
+ "commits": {"block": "#commits-block", "content": "#commit-log"},
46
+ "diff": {"block": "#diff-block", "content": "#diff-viewer"},
47
+ }
48
+
49
+ _BLOCK_ORDER: tuple[str, ...] = ("files", "commits", "diff")
50
+
51
+
52
+ class GituxApp(App[None]):
53
+ """Main Textual application for GITUX.
54
+
55
+ Composes a bento-grid layout with:
56
+ - TopAppBar header (repo name, branch)
57
+ - RepoStatsBar (repo stats)
58
+ - Three BlockContainers: Changed Files, Commit Log, File Diff
59
+ - GituxFooter (shortcuts, version, sync status)
60
+
61
+ Owns :class:`RepoPresenter` and :class:`CommitPresenter` and coordinates
62
+ data flow between them and the widget layer.
63
+ """
64
+
65
+ CSS_PATH = _CSS_PATH
66
+
67
+ BINDINGS = [
68
+ Binding("ctrl+p", "push", "Push", show=True),
69
+ Binding("r", "refresh", "Refresh", show=True),
70
+ Binding("c", "open_commit", "Commit", show=True),
71
+ Binding("b", "open_branches", "Branches", show=True),
72
+ Binding("q", "quit", "Quit", show=True),
73
+ Binding("question_mark", "help", "Help", show=True),
74
+ ]
75
+
76
+ def __init__(self) -> None:
77
+ super().__init__()
78
+ self._active_block: str = "files"
79
+ self._block_index: int = 0
80
+ self.repo = RepoPresenter()
81
+ self.presenter = CommitPresenter()
82
+
83
+ def compose(self) -> ComposeResult:
84
+ """Build the widget tree: header, stats bar, bento grid, footer."""
85
+ yield TopAppBar(id="top-bar")
86
+ yield RepoStatsBar(id="stats-bar")
87
+ with Vertical(id="main-content"):
88
+ with Horizontal(id="bento-top"):
89
+ yield BlockContainer(
90
+ id="files-block",
91
+ title="Changed Files",
92
+ subtitle="loading...",
93
+ content_widget=ChangedFilesPanel(id="changed-files"),
94
+ )
95
+ yield BlockContainer(
96
+ id="commits-block",
97
+ title="Commit Log",
98
+ subtitle="active: ",
99
+ content_widget=CommitLogWidget(id="commit-log"),
100
+ )
101
+ yield BlockContainer(
102
+ id="diff-block",
103
+ title="File Diff",
104
+ subtitle="staged diff",
105
+ content_widget=DiffViewerWidget(id="diff-viewer"),
106
+ )
107
+ yield GituxFooter()
108
+
109
+ def on_mount(self) -> None:
110
+ """Initialise data and activate the files block on startup."""
111
+ self._refresh_all()
112
+ self._activate_block("files")
113
+
114
+ def action_open_commit(self) -> None:
115
+ """Open the commit message modal (bound to ``c``)."""
116
+ self.push_screen(
117
+ CommitScreen(self.presenter),
118
+ self._handle_commit_result,
119
+ )
120
+
121
+ def _handle_commit_result(self, commit_hash: str | None) -> None:
122
+ """Callback after commit modal closes. Refresh on success."""
123
+ if commit_hash:
124
+ self.notify(f"Committed: {commit_hash}", severity="information")
125
+ self._refresh_all()
126
+
127
+ def action_push(self) -> None:
128
+ """Push commits to remote (bound to ``ctrl+p``)."""
129
+ self._run_push_worker()
130
+
131
+ def action_refresh(self) -> None:
132
+ """Refresh all panels from git (bound to ``r``)."""
133
+ self._refresh_all()
134
+
135
+ def action_help(self) -> None:
136
+ """Show the keyboard-shortcuts help modal (bound to ``?``)."""
137
+ self.push_screen(HelpScreen())
138
+
139
+ def action_open_branches(self) -> None:
140
+ """Open the branch-switch modal (bound to ``b``)."""
141
+ self.push_screen(
142
+ BranchScreen(self.repo),
143
+ self._handle_branch_selected,
144
+ )
145
+
146
+ def _handle_branch_selected(self, branch: str | None) -> None:
147
+ """Callback after the branch modal closes; refresh on success."""
148
+ if branch:
149
+ self.notify(f"Switched to {branch}", severity="information")
150
+ self._refresh_all()
151
+
152
+ def on_key(self, event: Key) -> None:
153
+ """Route keys: Tab cycles+activates blocks, all others go to active panel."""
154
+ if self.screen.is_modal:
155
+ return
156
+ key = event.key
157
+
158
+ # ── Tab: cycle blocks and activate block immediately ──
159
+ if key == "tab":
160
+ event.stop()
161
+ self._block_index = (self._block_index + 1) % len(_BLOCK_ORDER)
162
+ self._activate_block(_BLOCK_ORDER[self._block_index])
163
+ return
164
+
165
+ # ── All other navigational keys: route to active content panel ──
166
+ if key in ("up", "down", "enter", "escape", "s", "a", "A",
167
+ "page_up", "page_down", "home", "end"):
168
+ event.stop()
169
+ self._route_key_to_panel(key)
170
+
171
+ def _route_key_to_panel(self, key: str) -> None:
172
+ """Send a key event to the currently active content panel."""
173
+ if self._active_block == "files":
174
+ self._route_to_files(key)
175
+ elif self._active_block == "commits":
176
+ self._route_to_commits(key)
177
+ elif self._active_block == "diff":
178
+ self._route_to_diff(key)
179
+
180
+ def _route_to_files(self, key: str) -> None:
181
+ """Route a key press to the changed-files panel."""
182
+ panel = self.query_one("#changed-files", ChangedFilesPanel)
183
+ if key == "down":
184
+ panel.action_cursor_down()
185
+ elif key == "up":
186
+ panel.action_cursor_up()
187
+ elif key in ("enter", "s"):
188
+ panel.action_toggle_file()
189
+ elif key == "a":
190
+ panel.action_stage_all()
191
+ elif key == "A":
192
+ panel.action_unstage_all()
193
+
194
+ def _route_to_commits(self, key: str) -> None:
195
+ """Route a key press to the commit-log panel (log or detail mode)."""
196
+ panel = self.query_one("#commit-log", CommitLogWidget)
197
+ if key == "down":
198
+ if panel.is_detail_mode:
199
+ panel.scroll_down(animate=False)
200
+ else:
201
+ panel.action_cursor_down()
202
+ elif key == "up":
203
+ if panel.is_detail_mode:
204
+ panel.scroll_up(animate=False)
205
+ else:
206
+ panel.action_cursor_up()
207
+ elif key == "enter":
208
+ if panel.is_detail_mode:
209
+ return
210
+ line = panel.cursor_line
211
+ if not line:
212
+ return
213
+ commit_hash = extract_commit_hash(line)
214
+ if commit_hash is None:
215
+ return
216
+ details = self.repo.get_commit_details(commit_hash)
217
+ if not details:
218
+ return
219
+ panel.show_details(commit_hash, details)
220
+ elif key == "escape":
221
+ if panel.is_detail_mode:
222
+ panel.exit_details()
223
+ elif key == "page_down":
224
+ panel.scroll_page_down(animate=False)
225
+ elif key == "page_up":
226
+ panel.scroll_page_up(animate=False)
227
+ elif key == "home":
228
+ panel.scroll_home(animate=False)
229
+ elif key == "end":
230
+ panel.scroll_end(animate=False)
231
+
232
+ def _route_to_diff(self, key: str) -> None:
233
+ """Route a key press to the diff-viewer panel."""
234
+ panel = self.query_one("#diff-viewer", DiffViewerWidget)
235
+ if key == "down":
236
+ panel.scroll_down(animate=False)
237
+ elif key == "up":
238
+ panel.scroll_up(animate=False)
239
+ elif key == "page_down":
240
+ panel.scroll_page_down(animate=False)
241
+ elif key == "page_up":
242
+ panel.scroll_page_up(animate=False)
243
+ elif key == "home":
244
+ panel.scroll_home(animate=False)
245
+ elif key == "end":
246
+ panel.scroll_end(animate=False)
247
+
248
+ def _activate_block(self, block_name: str) -> None:
249
+ """Activate a bento block and deactivate the current one.
250
+
251
+ Updates block border styles and blurs keyboard focus so subsequent
252
+ key events go through :meth:`on_key`.
253
+ """
254
+ info = _BLOCK_MAP[block_name]
255
+
256
+ if self._active_block:
257
+ current_info = _BLOCK_MAP[self._active_block]
258
+ current_block = self.query_one(current_info["block"], BlockContainer)
259
+ current_block.set_active(False)
260
+
261
+ self._active_block = block_name
262
+ self._block_index = _BLOCK_ORDER.index(block_name)
263
+
264
+ new_block = self.query_one(info["block"], BlockContainer)
265
+ new_block.set_active(True)
266
+
267
+ # Clear keyboard focus so no widget can intercept subsequent keys
268
+ if self.screen.focused is not None:
269
+ self.screen.focused.blur()
270
+
271
+ @work(thread=True, exclusive=True)
272
+ async def _run_push_worker(self) -> None:
273
+ """Run push in a background thread to avoid blocking the UI."""
274
+ result = self.presenter.push()
275
+ self.call_from_thread(self._handle_push_result, result)
276
+
277
+ def _handle_push_result(self, result: PushResult) -> None:
278
+ """Notify the user whether push succeeded or failed, then refresh."""
279
+ if result.success:
280
+ self.notify("Pushed successfully", severity="information")
281
+ else:
282
+ self.notify(result.error or "Push failed", severity="error")
283
+ self._refresh_all()
284
+
285
+ def _refresh_all(self) -> None:
286
+ """Refresh every widget from git state: files, header, stats bar, log, diff, footer."""
287
+ try:
288
+ files = self.presenter.load_status()
289
+ except Exception as exc:
290
+ self.notify(f"Error loading status: {exc}", severity="error")
291
+ return
292
+
293
+ branch = self._get_branch_safe()
294
+ remote_status = self._get_remote_status_safe()
295
+ repo_info = self._get_repo_info_safe()
296
+ is_detached = self._get_detached_safe()
297
+
298
+ user = self._get_user_safe()
299
+ head_summary = self._get_head_summary_safe()
300
+ default_branch = self._get_default_branch_safe()
301
+ operation = self._get_operation_state_safe()
302
+
303
+ file_counts = FileCounts.from_status(files)
304
+ wip = derive_wip_state(
305
+ file_counts,
306
+ operation,
307
+ has_commits=head_summary is not None, # empty repo → UNKNOWN
308
+ )
309
+
310
+ header = self.query_one("#top-bar", TopAppBar)
311
+ header.update_display(
312
+ repo_name=repo_info.name if repo_info else "",
313
+ owner=repo_info.owner if repo_info else "",
314
+ branch=branch,
315
+ is_detached=is_detached,
316
+ default_branch=default_branch,
317
+ wip_state=wip,
318
+ )
319
+
320
+ stats_bar = self.query_one("#stats-bar", RepoStatsBar)
321
+ stats_bar.update_stats(
322
+ user=user,
323
+ head_summary=head_summary,
324
+ file_counts=file_counts,
325
+ ahead=remote_status.ahead if remote_status else 0,
326
+ behind=remote_status.behind if remote_status else 0,
327
+ remote=remote_status.remote if remote_status else "(local)",
328
+ remote_branch=remote_status.branch if remote_status else "",
329
+ repo_name=repo_info.name if repo_info else "",
330
+ )
331
+
332
+ changed_files = self.query_one("#changed-files", ChangedFilesPanel)
333
+ changed_files.set_files(files)
334
+ changed_files.set_on_toggle(self._on_file_toggle)
335
+ changed_files.set_on_selection_change(self._on_file_selected)
336
+ changed_files.set_on_stage_all(self._on_stage_all)
337
+ changed_files.set_on_unstage_all(self._on_unstage_all)
338
+
339
+ self.query_one("#files-block", BlockContainer).set_header_subtitle(
340
+ f"{len(files)} items"
341
+ )
342
+
343
+ commit_log = self.query_one("#commit-log", CommitLogWidget)
344
+ try:
345
+ log_text = self._get_commit_log_safe(max_count=30)
346
+ commit_log.show_log(log_text)
347
+ except Exception:
348
+ commit_log.clear()
349
+
350
+ self.query_one("#commits-block", BlockContainer).set_header_subtitle(
351
+ f"active: {branch}" if branch else "active: "
352
+ )
353
+
354
+ self._on_file_selected()
355
+
356
+ footer = self.query_one(GituxFooter)
357
+ sync_ok = remote_status is not None and remote_status.behind == 0
358
+ footer.update_sync(ok=sync_ok)
359
+
360
+ def _on_file_selected(self) -> None:
361
+ """Update the diff viewer when the cursor moves to a different file."""
362
+ changed_files = self.query_one("#changed-files", ChangedFilesPanel)
363
+ diff_viewer = self.query_one("#diff-viewer", DiffViewerWidget)
364
+ file = changed_files.selected_file
365
+
366
+ if file is None:
367
+ preview = self.presenter.get_staged_preview()
368
+ if preview:
369
+ diff_viewer.show_diff(preview)
370
+ else:
371
+ diff_viewer.clear()
372
+ self.query_one("#diff-block", BlockContainer).set_header_subtitle("staged diff")
373
+ return
374
+
375
+ diff_text = self.presenter.get_diff(file)
376
+ if not diff_text:
377
+ diff_viewer.show_binary_placeholder()
378
+ else:
379
+ diff_viewer.show_diff(diff_text)
380
+ self.query_one("#diff-block", BlockContainer).set_header_subtitle(file.path)
381
+
382
+ def _on_file_toggle(self, file: FileStatus) -> None:
383
+ """Stage or unstage a single file, then refresh all panels."""
384
+ try:
385
+ if file.is_staged:
386
+ self.presenter.unstage_files([file.path])
387
+ else:
388
+ self.presenter.stage_files([file.path])
389
+ self._refresh_all()
390
+ except Exception as exc:
391
+ self.notify(f"Error: {exc}", severity="error")
392
+
393
+ def _on_stage_all(self) -> None:
394
+ """Stage all unstaged files, then refresh."""
395
+ try:
396
+ unstaged = self.presenter.unstaged_files
397
+ if unstaged:
398
+ self.presenter.stage_files([f.path for f in unstaged])
399
+ self._refresh_all()
400
+ except Exception as exc:
401
+ self.notify(f"Error staging all: {exc}", severity="error")
402
+
403
+ def _on_unstage_all(self) -> None:
404
+ """Unstage all staged files, then refresh."""
405
+ try:
406
+ staged = self.presenter.staged_files
407
+ if staged:
408
+ self.presenter.unstage_files([f.path for f in staged])
409
+ self._refresh_all()
410
+ except Exception as exc:
411
+ self.notify(f"Error unstaging all: {exc}", severity="error")
412
+
413
+ def _get_commit_log_safe(self, max_count: int = 30) -> str:
414
+ """Return commit log text, or empty string on error."""
415
+ return self.repo.get_commit_log(max_count)
416
+
417
+ def _get_branch_safe(self) -> str:
418
+ """Return current branch name, or empty string on error."""
419
+ return self.repo.get_current_branch()
420
+
421
+ def _get_user_safe(self) -> str:
422
+ """Return the configured git user, or empty string on error."""
423
+ return self.repo.get_user()
424
+
425
+ def _get_head_summary_safe(self) -> HeadSummary | None:
426
+ """Return the HEAD commit summary, or None on error."""
427
+ return self.repo.get_head_summary()
428
+
429
+ def _get_default_branch_safe(self) -> str:
430
+ """Return the default branch name, or empty string on error."""
431
+ return self.repo.get_default_branch()
432
+
433
+ def _get_operation_state_safe(self) -> OperationState | None:
434
+ """Return the merge/rebase operation state, or None on error."""
435
+ return self.repo.get_operation_state()
436
+
437
+ def _get_remote_status_safe(self) -> RemoteStatus | None:
438
+ """Return remote status, or ``None`` on error."""
439
+ return self.repo.get_remote_status()
440
+
441
+ def _get_repo_info_safe(self) -> RepoInfo | None:
442
+ """Return repo info, or ``None`` on error."""
443
+ return self.repo.get_repo_info()
444
+
445
+ def _get_detached_safe(self) -> bool:
446
+ """Return whether HEAD is detached (``False`` on error)."""
447
+ return self.repo.is_detached_head()
448
+
449
+
450
+ if __name__ == "__main__":
451
+ app = GituxApp()
452
+ app.run()