gitnc 0.1.2__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.
gitnc/app.py ADDED
@@ -0,0 +1,4699 @@
1
+ """Main Textual application for GitNightCommander."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import shlex
9
+ import subprocess
10
+ import tempfile
11
+ import textwrap
12
+ import threading
13
+ from collections.abc import Callable, Mapping
14
+ from contextlib import ExitStack
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import IO, ClassVar, NamedTuple
18
+
19
+ from rich.text import Text
20
+ from textual import work
21
+ from textual.app import (
22
+ App,
23
+ ComposeResult,
24
+ InvalidThemeError,
25
+ ScreenStackError,
26
+ SuspendNotSupported,
27
+ )
28
+ from textual.binding import Binding, BindingType
29
+ from textual.containers import Horizontal, ScrollableContainer, Vertical
30
+ from textual.css.query import QueryError
31
+ from textual.message import Message
32
+ from textual.screen import ModalScreen
33
+ from textual.types import NoActiveAppError
34
+ from textual.widget import Widget
35
+ from textual.widgets import (
36
+ Button,
37
+ ContentSwitcher,
38
+ Footer,
39
+ Header,
40
+ Label,
41
+ ListItem,
42
+ ListView,
43
+ OptionList,
44
+ Static,
45
+ TextArea,
46
+ )
47
+ from textual.widgets.option_list import Option
48
+
49
+ from .git import ANSI_SGR_PATTERN, GIT, Commit, GitCode, GitEntry, GitFilelist
50
+ from .terminal import Terminal, started_from_midnight_commander
51
+ from .themes import load_all as _load_all_themes
52
+ from .ui_theme import UITheme
53
+
54
+ # Raised when a widget asks for screen geometry before it is mounted, or with
55
+ # an empty screen stack. Every caller centres itself on the screen and falls
56
+ # back to a fixed offset, so losing the measurement is not an error.
57
+ NO_SCREEN_ERRORS = (NoActiveAppError, ScreenStackError)
58
+
59
+ # `query_one` raises QueryError when the widget genuinely isn't there. Before
60
+ # the app is mounted the lookup fails earlier still — there is no screen to
61
+ # search — so guard those two cases too. Every caller treats all three the
62
+ # same way: the widget isn't available, so skip the update.
63
+ WIDGET_LOOKUP_ERRORS = (QueryError, ScreenStackError, NoActiveAppError)
64
+
65
+ MODE_SHORT = "short"
66
+ MODE_LONG = "long"
67
+
68
+ # Quick filters for the file list, in the order `f` cycles through them, with
69
+ # the text the status line under the list shows for each. FILTER_NONE lists
70
+ # every file `git status` reports; FILTER_UNTRACKED_DIRS shows a directory
71
+ # holding nothing git has ever tracked as one `dir/` row instead of every file
72
+ # below it, which is what keeps a freshly unpacked tree from burying the
73
+ # changes that matter.
74
+ FILTER_NONE = "none"
75
+ FILTER_UNTRACKED_DIRS = "untracked-dirs"
76
+ FILE_LIST_FILTERS: list[str] = [FILTER_NONE, FILTER_UNTRACKED_DIRS]
77
+ FILE_LIST_FILTER_LABELS: dict[str, str] = {
78
+ FILTER_NONE: "Filter: none",
79
+ FILTER_UNTRACKED_DIRS: "Filter: untracked folders collapsed",
80
+ }
81
+
82
+ # Menu entries that belong to a mutually exclusive group carry a radio marker
83
+ # in a fixed-width gutter; every other entry pads that gutter with blanks so
84
+ # all labels start in the same column no matter which mode is active.
85
+ # Turbo Vision / Midnight Commander style accelerator marker: the character
86
+ # after it is the one highlighted in the menu and bound as its shortcut.
87
+ MENU_MNEMONIC_MARKER = "&"
88
+
89
+ MENU_RADIO_ON = "\u25cf"
90
+ MENU_RADIO_OFF = "\u25cb"
91
+ MENU_MARKER_BLANK = " "
92
+ MENU_MARKER_GAP = " "
93
+
94
+ # An entry that opens a nested menu instead of acting advertises it with a
95
+ # right-aligned arrow, in the same column the shortcut hints use.
96
+ MENU_SUBMENU_MARKER = "\u25b6"
97
+
98
+ SHORT_NAME_WIDTH = 25
99
+ LONG_NAME_WIDTH = 60
100
+ STATUS_WIDTH = 6
101
+ SIZE_WIDTH = 10
102
+ MTIME_WIDTH = 16
103
+ COLUMN_GAP = 2
104
+ STATUS_LIST_ITEM_HORIZONTAL_PADDING = 2
105
+
106
+
107
+ # Drafting a commit message shells out to whatever tool the user names in
108
+ # Options -> Settings. There is no default command — until one is filled in
109
+ # the Draft button just says so — but the prompt has a usable default, so
110
+ # naming the command is all the setup a tool needs.
111
+ COMMIT_DRAFT_COMMAND_DEFAULT = ""
112
+ COMMIT_DRAFT_PROMPT_DEFAULT = "Write a concise git commit message for these files:"
113
+ # The tool is typically an LLM CLI, so it gets minutes rather than seconds
114
+ # before the worker running it gives up.
115
+ COMMIT_DRAFT_TIMEOUT_SECONDS = 300
116
+ # The killed process closes its pipes, so the stderr reader is already on its
117
+ # way out by the time it is joined; the timeout only stops a wedged reader
118
+ # from taking the worker down with it.
119
+ COMMIT_DRAFT_JOIN_SECONDS = 5
120
+ # Rows of the tool's output shown under the message while it runs, and the
121
+ # number of lines kept — a chatty tool would otherwise grow the log without
122
+ # bound for output nobody scrolls back to.
123
+ COMMIT_DRAFT_OUTPUT_ROWS = 6
124
+ COMMIT_DRAFT_OUTPUT_LINES = 200
125
+ # Asked at startup under Midnight Commander, which reads the keyboard itself
126
+ # and forwards it to its subshell, keeping Ctrl+O for its own panels.
127
+ MIDNIGHT_COMMANDER_WARNING_TITLE = "Started from Midnight Commander"
128
+ MIDNIGHT_COMMANDER_WARNING = (
129
+ "mc sees the keyboard before this app does. Ctrl+O switches back to mc's "
130
+ "panels instead of the previous screen, and other shortcuts may reach the "
131
+ "wrong app and behave unpredictably."
132
+ )
133
+ # Wide enough for the warning to wrap into a few readable lines.
134
+ MIDNIGHT_COMMANDER_DIALOG_WIDTH = 60
135
+
136
+ # Where the commit message is written: the app's own dialog, the editor named
137
+ # by $EDITOR, or a command line typed into Options -> Settings. The last two
138
+ # edit a temporary file on the previous screen.
139
+ COMMIT_EDITOR_BUILTIN = "builtin"
140
+ COMMIT_EDITOR_ENVIRONMENT = "environment"
141
+ COMMIT_EDITOR_COMMAND = "command"
142
+ COMMIT_EDITOR_MODES = (COMMIT_EDITOR_BUILTIN, COMMIT_EDITOR_ENVIRONMENT, COMMIT_EDITOR_COMMAND)
143
+ # The temporary file's name is git's own, which is what tells vim, Emacs and
144
+ # VS Code to open it in their git-commit mode.
145
+ COMMIT_EDITOR_FILENAME = "COMMIT_EDITMSG"
146
+ # Git's `--cleanup=scissors` marker: everything from it down is the help text
147
+ # and the file list, and nothing above it is dropped — so a message line that
148
+ # starts with `#` (an issue number, say) survives, as it would not if every
149
+ # `#` line were read as a comment.
150
+ COMMIT_EDITOR_SCISSORS = "# ------------------------ >8 ------------------------"
151
+ # Rows the suggestion picker shows before it starts scrolling. A tool asked
152
+ # for a handful of messages rarely returns more.
153
+ SUGGESTION_DIALOG_MAX_ROWS = 12
154
+
155
+ DEFAULT_BRANCH_DEFAULT = "master"
156
+
157
+ # Top-level menus: (label, widget id). The "&" marks the accelerator letter,
158
+ # highlighted in the bar and opened with Alt+that letter.
159
+ MENU_BAR: list[tuple[str, str]] = [
160
+ ("&File", "menu_file"),
161
+ ("&Repo", "menu_repo"),
162
+ ("&View", "menu_view"),
163
+ ("&Options", "menu_options"),
164
+ ("&Help", "menu_help"),
165
+ ]
166
+
167
+ # Whole-repository commands. They are their own menu rather than more entries
168
+ # under File, which is about the file under the cursor (plus Refresh and
169
+ # Quit); pull, push and stash act on the repo no matter what is highlighted.
170
+ REPO_MENU: list[tuple[str, str]] = [
171
+ ("&Pull", "pull"),
172
+ ("P&ush", "push"),
173
+ ("&Stash", "stash"),
174
+ ("Stash p&op", "stash_pop"),
175
+ ]
176
+
177
+ VIEW_MENU: list[tuple[str, str]] = [
178
+ ("&Status", "view_status"),
179
+ ("&History", "view_history"),
180
+ ]
181
+
182
+ MIDNIGHT_COMMANDER_THEME_NAME = "midnight-commander"
183
+
184
+ THEMES: list[UITheme] = _load_all_themes()
185
+ _UI_THEMES_BY_NAME: dict[str, UITheme] = {theme.name: theme for theme in THEMES}
186
+ MIDNIGHT_COMMANDER_THEME: UITheme = _UI_THEMES_BY_NAME[MIDNIGHT_COMMANDER_THEME_NAME]
187
+ THEME_MENU: list[tuple[str, str]] = [(theme.name, f"set_theme('{theme.name}')") for theme in THEMES]
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class Submenu:
192
+ """A nested menu: entries that open another dropdown instead of acting.
193
+
194
+ One entry in `SUBMENUS` is all a nested menu needs. It names the dropdown
195
+ that `GitNightCommanderApp.compose` mounts, the action that opens it, and the
196
+ menu-bar label of the parent it cascades out of — and, because
197
+ `MENU_SUBMENU_ACTIONS` is derived from the registry, the arrow marker the
198
+ parent entry shows appears without a second place to update.
199
+ """
200
+
201
+ name: str
202
+ items: list[tuple[str, str]]
203
+ parent_id: str
204
+
205
+ @property
206
+ def dropdown_id(self) -> str:
207
+ """The id of the `DropdownMenu` widget holding this menu's entries."""
208
+ return f"dropdown_{self.name}"
209
+
210
+ @property
211
+ def action(self) -> str:
212
+ """What the parent menu's entry runs to open this menu."""
213
+ return f"open_submenu('{self.name}')"
214
+
215
+
216
+ SUBMENUS: list[Submenu] = [
217
+ Submenu(name="theme", items=THEME_MENU, parent_id="menu_options"),
218
+ ]
219
+ SUBMENUS_BY_NAME: dict[str, Submenu] = {submenu.name: submenu for submenu in SUBMENUS}
220
+
221
+
222
+ OPTIONS_MENU: list[tuple[str, str]] = [
223
+ ("&Theme", SUBMENUS_BY_NAME["theme"].action),
224
+ ("&Settings", "settings"),
225
+ ]
226
+
227
+ HELP_MENU: list[tuple[str, str]] = [
228
+ ("&Keys", "keys"),
229
+ ]
230
+
231
+
232
+ # (key, action_name, footer_description, show_in_footer)
233
+ #
234
+ # Two key shapes, split by what the action is about. Function keys are the
235
+ # file-manager verbs every TUI in this lineage has put on that row (F3 view,
236
+ # F5 refresh, F7/F8 restore and delete) plus the chrome (F9 menu, F10 quit);
237
+ # bare letters are the git verbs — stage, unstage, commit, and now the repo
238
+ # commands. Within a letter pair, shift is the counterpart: `p` pulls and `P`
239
+ # pushes, `z` stashes and `Z` pops. The four new ones are `show=False`
240
+ # deliberately: the footer already runs past 80 columns with eleven entries,
241
+ # and they are advertised in the Repo menu and Help -> Keys instead.
242
+ SHORTCUTS: list[tuple[str, str, str, bool]] = [
243
+ ("f10", "quit", "Quit", True),
244
+ ("f5", "refresh", "Refresh", True),
245
+ ("ctrl+o", "toggle_previous_screen", "Prev Screen", True),
246
+ ("f3", "toggle_file_list", "Short/long list", True),
247
+ ("f", "toggle_file_filter", "Filter", False),
248
+ ("s", "stage_file", "Stage", True),
249
+ ("u", "unstage_file", "Unstage", True),
250
+ ("f7", "restore_file", "Restore", True),
251
+ ("f8", "delete_file", "Delete", True),
252
+ ("c", "commit_files", "Commit", True),
253
+ ("p", "pull", "Pull", False),
254
+ ("P", "push", "Push", False),
255
+ ("z", "stash", "Stash", False),
256
+ ("Z", "stash_pop", "Stash pop", False),
257
+ ("ctrl+u", "clear_selection", "Clear selection", True),
258
+ ("f9", "open_menu", "Menu", True),
259
+ ("f1", "keys", "Keys", False),
260
+ ("escape", "escape", "Back / close", False),
261
+ ]
262
+
263
+ # (key, action_name) — extra keys reaching an action SHORTCUTS already lists.
264
+ # The primary key is the one the footer, the menu hints and the app's own
265
+ # docs advertise; an alias only has to work. Delete carries both because the
266
+ # function-key row is the one every TUI file manager has used since Norton
267
+ # Commander, while the dedicated Del key is missing from many laptops.
268
+ # Commit carries F2 because that is the key that submits the message inside
269
+ # the dialog, so the same key opens and closes the flow.
270
+ # Quit carries `q` because F10 is not ours to rely on: a terminal emulator
271
+ # can keep it for itself — VS Code's integrated terminal hands it to the
272
+ # debugger's Step Over and never forwards it — and a key the app never
273
+ # receives looks exactly like an app that ignores it. A bare letter always
274
+ # arrives. It stays out of the priority binding F10 has, so unlike F10 it
275
+ # cannot fire from inside a dialog: `q` there is someone typing a commit
276
+ # message.
277
+ SHORTCUT_ALIASES: list[tuple[str, str]] = [
278
+ ("delete", "delete_file"),
279
+ ("f2", "commit_files"),
280
+ ("q", "quit"),
281
+ ]
282
+
283
+
284
+ # Keys whose Textual name reads badly in a menu hint column.
285
+ MENU_KEY_NAMES: dict[str, str] = {
286
+ "delete": "Del",
287
+ "escape": "Esc",
288
+ "ctrl": "Ctrl",
289
+ "shift": "Shift",
290
+ "alt": "Alt",
291
+ }
292
+
293
+ # Gap between a dropdown label and its right-aligned shortcut hint.
294
+ MENU_HINT_GAP = 2
295
+
296
+
297
+ def _is_shifted_letter(key: str) -> bool:
298
+ """True for a shift+letter binding, which Textual names by the letter.
299
+
300
+ `Key.key` for shift+P is just `"P"`, so an uppercase single character is
301
+ the whole of what distinguishes it from `p` — one cell apart in a hint
302
+ column, which is why both places that render a key spell it out.
303
+ """
304
+ return len(key) == 1 and key.isupper()
305
+
306
+
307
+ def _format_shortcut_key(key: str) -> str:
308
+ """Render a Textual key name the way a menu hint column shows it."""
309
+ if _is_shifted_letter(key):
310
+ return f"Shift+{key}"
311
+ return "+".join(MENU_KEY_NAMES.get(part, part.upper()) for part in key.split("+"))
312
+
313
+
314
+ def _shortcut_key_display(key: str) -> str | None:
315
+ """`F10` for a function key, `Shift+P` for a shifted letter, else None.
316
+
317
+ Textual names function keys in lower case (`f10`) and shows that name
318
+ verbatim, and it shows a shift+letter as the bare uppercase letter — the
319
+ two places a shortcut would read differently from the menu hint column
320
+ and from every label in the app. None means "no override", so Textual
321
+ keeps its own display for the rest — `^o` for `ctrl+o`, which reads
322
+ better than spelling the modifier out.
323
+ """
324
+ if re.fullmatch(r"f\d+", key):
325
+ return key.upper()
326
+ if _is_shifted_letter(key):
327
+ return f"Shift+{key}"
328
+ return None
329
+
330
+
331
+ # Shortcut hint per action, so a dropdown entry advertises the same key the
332
+ # footer does without the menu tables repeating it. Aliases stay out of it:
333
+ # the hint column has room for one key, and it shows the primary one.
334
+ MENU_HINTS_BY_ACTION: dict[str, str] = {
335
+ action: _format_shortcut_key(key) for key, action, _, _ in SHORTCUTS
336
+ }
337
+
338
+ # Footer description per action, so an alias binding describes itself the same
339
+ # way the primary key does.
340
+ SHORTCUT_DESCRIPTIONS_BY_ACTION: dict[str, str] = {
341
+ action: description for _, action, description, _ in SHORTCUTS
342
+ }
343
+
344
+ # Alias keys per action, in table order, for the one place that lists every
345
+ # way to reach an action: the help dialog.
346
+ ALIAS_KEYS_BY_ACTION: dict[str, list[str]] = {}
347
+ for _alias_key, _alias_action in SHORTCUT_ALIASES:
348
+ ALIAS_KEYS_BY_ACTION.setdefault(_alias_action, []).append(_alias_key)
349
+
350
+ # SHORTCUTS with each action's aliases folded into the key column, function
351
+ # keys and shifted letters spelled the same way the footer and the menu hints
352
+ # show them.
353
+ HELP_SHORTCUTS: list[tuple[str, str, str, bool]] = [
354
+ (
355
+ ", ".join(
356
+ _shortcut_key_display(k) or k for k in [key, *ALIAS_KEYS_BY_ACTION.get(action, [])]
357
+ ),
358
+ action,
359
+ description,
360
+ show,
361
+ )
362
+ for key, action, description, show in SHORTCUTS
363
+ ]
364
+
365
+ # Actions that open a nested menu rather than doing something. They carry the
366
+ # submenu arrow where a leaf entry would carry its shortcut hint. Derived from
367
+ # the registry, so registering a submenu is what marks its parent entry.
368
+ MENU_SUBMENU_ACTIONS: frozenset[str] = frozenset(submenu.action for submenu in SUBMENUS)
369
+
370
+
371
+ def _dropdown_id(menu_id: str) -> str:
372
+ """The id of the dropdown belonging to the menu-bar label *menu_id*."""
373
+ return menu_id.replace("menu_", "dropdown_")
374
+
375
+
376
+ def _split_mnemonic(label: str) -> tuple[str, int]:
377
+ """Split *label* into its plain text and the accelerator character index.
378
+
379
+ Menu labels mark their accelerator Turbo Vision style, with a marker in
380
+ front of one character ("&File"). The marker never reaches the screen: it
381
+ is stripped here, and the index it leaves behind says which character the
382
+ renderer highlights and which key selects the entry. A doubled marker
383
+ ("&&") escapes to a literal one. Labels without a marker get index -1.
384
+ """
385
+ plain: list[str] = []
386
+ index = -1
387
+ position = 0
388
+ while position < len(label):
389
+ char = label[position]
390
+ if char != MENU_MNEMONIC_MARKER or position + 1 >= len(label):
391
+ plain.append(char)
392
+ position += 1
393
+ continue
394
+ marked = label[position + 1]
395
+ if marked != MENU_MNEMONIC_MARKER and index == -1:
396
+ index = len(plain)
397
+ plain.append(marked)
398
+ position += 2
399
+ return "".join(plain), index
400
+
401
+
402
+ def _menu_plain_label(label: str) -> str:
403
+ """*label* as the user sees it, with the accelerator markers removed."""
404
+ return _split_mnemonic(label)[0]
405
+
406
+
407
+ def _menu_mnemonic_key(label: str) -> str | None:
408
+ """The lower-case key that selects *label*, or None when it marks none."""
409
+ plain, index = _split_mnemonic(label)
410
+ return plain[index].lower() if index >= 0 else None
411
+
412
+
413
+ def _mnemonic_markup(text: str, index: int, *, style: str) -> str:
414
+ """Wrap the character at *index* of *text* in *style*, as content markup."""
415
+ if index < 0:
416
+ return text
417
+ return f"{text[:index]}[{style}]{text[index]}[/]{text[index + 1 :]}"
418
+
419
+
420
+ def _menu_mnemonic_style(widget: Widget) -> str:
421
+ """The active theme's accelerator style, with a fallback for no live app.
422
+
423
+ Menu chrome renders while composing, which in tests happens before the
424
+ widget is wired up to an app.
425
+ """
426
+ try:
427
+ return widget.app._active_ui_theme().menu_mnemonic # pyright: ignore[reportAttributeAccessIssue]
428
+ except NO_SCREEN_ERRORS:
429
+ return MIDNIGHT_COMMANDER_THEME.menu_mnemonic
430
+
431
+
432
+ def _menu_hint(action: str) -> str:
433
+ """What *action* shows in a dropdown's right-hand column.
434
+
435
+ An entry that opens a nested menu gets the submenu arrow, so the user can
436
+ tell it apart from a leaf entry; everything else gets its shortcut key, if
437
+ it has one.
438
+ """
439
+ if action in MENU_SUBMENU_ACTIONS:
440
+ return MENU_SUBMENU_MARKER
441
+ return MENU_HINTS_BY_ACTION.get(action, "")
442
+
443
+
444
+ def _menu_option_labels(items: list[tuple[str, str]]) -> list[str]:
445
+ """Pad *items*' labels so their right-hand column lines up on the edge.
446
+
447
+ Accelerator markers are stripped, so the widths are the ones the user
448
+ sees. Items with neither a shortcut nor a submenu arrow still get the
449
+ padding, so every label in the menu occupies the same width and the
450
+ column stays straight.
451
+ """
452
+ labels = [_menu_plain_label(label) for label, _ in items]
453
+ hints = [_menu_hint(action) for _, action in items]
454
+ hint_width = max((len(hint) for hint in hints), default=0)
455
+ if not hint_width:
456
+ return labels
457
+ label_width = max(len(label) for label in labels)
458
+ return [
459
+ f"{label:<{label_width}}{' ' * MENU_HINT_GAP}{hint:>{hint_width}}"
460
+ for label, hint in zip(labels, hints)
461
+ ]
462
+
463
+
464
+ def _menu_option_markup(items: list[tuple[str, str]], *, style: str) -> list[str]:
465
+ """`_menu_option_labels`, with each accelerator letter highlighted.
466
+
467
+ The marker is stripped before the label is padded, and the padding is
468
+ appended, so an accelerator's index in the padded label is the one
469
+ `_split_mnemonic` reported for the raw label.
470
+ """
471
+ return [
472
+ _mnemonic_markup(padded, _split_mnemonic(label)[1], style=style)
473
+ for padded, (label, _) in zip(_menu_option_labels(items), items)
474
+ ]
475
+
476
+
477
+ class MenuLabel(Static):
478
+ """A clickable label used as a top-level menu item in the MenuBar.
479
+
480
+ Takes its text with an accelerator marker ("&File") and renders the
481
+ marked character in the theme's accelerator style, so the bar advertises
482
+ the Alt+letter that opens it.
483
+ """
484
+
485
+ DEFAULT_CSS = """
486
+ MenuLabel {
487
+ padding: 0 1;
488
+ height: 1;
489
+ width: auto;
490
+ color: $menu-bar-foreground;
491
+ }
492
+ /* Hover tints the bar with its own text color and the open menu reverses
493
+ it, so both states are derived from the two chrome colors a theme sets
494
+ and stay legible whatever those are. */
495
+ MenuLabel:hover {
496
+ background: $menu-bar-foreground 15%;
497
+ color: $menu-bar-foreground;
498
+ }
499
+ MenuLabel.-active {
500
+ background: $menu-bar-foreground;
501
+ color: $menu-bar-background;
502
+ }
503
+ """
504
+
505
+ class Pressed(Message):
506
+ def __init__(self, label: MenuLabel) -> None:
507
+ super().__init__()
508
+ self.label = label
509
+
510
+ def __init__(self, label: str, **kwargs) -> None:
511
+ self._plain, self._mnemonic_index = _split_mnemonic(label)
512
+ # The styled text needs the app's theme, which is only reachable once
513
+ # mounted; until then the label reads as plain text.
514
+ super().__init__(self._plain, **kwargs)
515
+
516
+ @property
517
+ def mnemonic(self) -> str | None:
518
+ """The lower-case key that opens this menu, or None."""
519
+ return self._plain[self._mnemonic_index].lower() if self._mnemonic_index >= 0 else None
520
+
521
+ def apply_mnemonic_style(self) -> None:
522
+ """Re-render the label in the active theme's accelerator style."""
523
+ self.update(
524
+ _mnemonic_markup(
525
+ self._plain,
526
+ self._mnemonic_index,
527
+ style=_menu_mnemonic_style(self),
528
+ )
529
+ )
530
+
531
+ def on_mount(self) -> None:
532
+ self.apply_mnemonic_style()
533
+
534
+ def on_click(self) -> None:
535
+ self.post_message(self.Pressed(self))
536
+
537
+
538
+ class MenuBar(Widget):
539
+ """Horizontal bar docked at the top holding File, View and Options menus."""
540
+
541
+ DEFAULT_CSS = """
542
+ MenuBar {
543
+ height: 1;
544
+ dock: top;
545
+ layout: horizontal;
546
+ background: $menu-bar-background;
547
+ color: $menu-bar-foreground;
548
+ }
549
+ """
550
+
551
+ def compose(self) -> ComposeResult:
552
+ for label, menu_id in MENU_BAR:
553
+ yield MenuLabel(label, id=menu_id)
554
+
555
+ def on_menu_label_pressed(self, event: MenuLabel.Pressed) -> None:
556
+ label = event.label
557
+ region = label.region
558
+ self.app.toggle_menu(label.id, region.x, region.y + 1) # type: ignore[arg-type]
559
+
560
+
561
+ class DropdownMenu(Widget):
562
+ """Floating dropdown rendered on the overlay layer."""
563
+
564
+ DEFAULT_CSS = """
565
+ DropdownMenu {
566
+ layer: overlay;
567
+ /* `show_at` places menus in screen cells, so the offset has to be
568
+ read from the container's origin. Without this a menu shown next
569
+ to another one — a cascading submenu — starts after its visible
570
+ siblings instead of where it was put. */
571
+ position: absolute;
572
+ display: none;
573
+ background: $panel;
574
+ border: solid $accent;
575
+ width: auto;
576
+ min-width: 0;
577
+ height: auto;
578
+ padding: 0;
579
+ }
580
+ DropdownMenu OptionList {
581
+ width: 1fr;
582
+ height: auto;
583
+ border: none;
584
+ background: transparent;
585
+ padding: 0;
586
+ }
587
+ DropdownMenu OptionList:focus {
588
+ border: none;
589
+ outline: none;
590
+ }
591
+ """
592
+
593
+ def __init__(self, items: list[tuple[str, str]], **kwargs) -> None:
594
+ super().__init__(**kwargs)
595
+ self._items = items
596
+
597
+ def compose(self) -> ComposeResult:
598
+ yield OptionList(*[Option(label) for label in self._option_markup()])
599
+
600
+ def _option_markup(self) -> list[str]:
601
+ return _menu_option_markup(self._items, style=_menu_mnemonic_style(self))
602
+
603
+ def _render_options(self) -> None:
604
+ try:
605
+ option_list = self.query_one(OptionList)
606
+ except WIDGET_LOOKUP_ERRORS:
607
+ return
608
+ option_list.clear_options()
609
+ for label in self._option_markup():
610
+ option_list.add_option(Option(label))
611
+
612
+ def set_items(self, items: list[tuple[str, str]]) -> None:
613
+ self._items = items
614
+ self._render_options()
615
+
616
+ def apply_mnemonic_style(self) -> None:
617
+ """Re-render the entries in the active theme's accelerator style."""
618
+ self._render_options()
619
+
620
+ @property
621
+ def menu_width(self) -> int:
622
+ """How wide this menu renders, border included.
623
+
624
+ Computed from the items rather than read back from the layout, so a
625
+ menu can be placed against another one that Textual has not laid out
626
+ yet (a cascading submenu opens in the same tick as its parent).
627
+ """
628
+ return max(len(label) for label in _menu_option_labels(self._items)) + 4
629
+
630
+ def index_of_action(self, action: str) -> int | None:
631
+ """The row *action* sits on, or None when this menu doesn't run it."""
632
+ return next((i for i, (_, item) in enumerate(self._items) if item == action), None)
633
+
634
+ def highlight_action(self, action: str) -> None:
635
+ """Put the cursor on the entry that runs *action*, if there is one.
636
+
637
+ Used when a nested menu closes back to its parent, so the cursor
638
+ lands on the entry the user opened it from instead of the top.
639
+ """
640
+ index = self.index_of_action(action)
641
+ if index is None:
642
+ return
643
+ try:
644
+ option_list = self.query_one(OptionList)
645
+ except WIDGET_LOOKUP_ERRORS:
646
+ return
647
+ option_list.highlighted = index
648
+
649
+ def action_for_mnemonic(self, char: str) -> str | None:
650
+ """The action of the entry whose accelerator is *char*, if any."""
651
+ for label, action in self._items:
652
+ if _menu_mnemonic_key(label) == char.lower():
653
+ return action
654
+ return None
655
+
656
+ async def _run_menu_action(self, action: str) -> None:
657
+ self.display = False
658
+ await self.app.run_action(action)
659
+
660
+ async def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
661
+ event.stop()
662
+ await self._run_menu_action(self._items[event.option_index][1])
663
+
664
+ async def on_key(self, event) -> None:
665
+ """Run the entry whose highlighted letter was typed.
666
+
667
+ Any other bare character is swallowed rather than passed on: while a
668
+ menu is open the app's single-letter bindings (`s`, `u`, `c`) must not
669
+ fire behind it. Only keys whose name *is* their character are claimed,
670
+ which leaves `space` and `enter` to the OptionList's own selection
671
+ handling and lets `alt+letter` bubble up to the menu bar.
672
+ """
673
+ char = event.character
674
+ if not char or event.key != char:
675
+ return
676
+ event.stop()
677
+ event.prevent_default()
678
+ action = self.action_for_mnemonic(char)
679
+ if action is not None:
680
+ await self._run_menu_action(action)
681
+
682
+ def show_at(self, x: int, y: int) -> None:
683
+ self.styles.width = self.menu_width
684
+ self.styles.offset = (x, y)
685
+ self.display = True
686
+ option_list = self.query_one(OptionList)
687
+ if option_list.highlighted is None:
688
+ # A menu opens with its cursor on the first entry: an open menu
689
+ # with no visible cursor gives the user nothing to navigate from.
690
+ option_list.highlighted = 0
691
+ _ = self.app.set_focus(option_list)
692
+
693
+ def hide(self) -> None:
694
+ self.display = False
695
+
696
+
697
+ CONFIRM_DIALOG_MAX_COMMAND_ROWS = 10
698
+ # The commands area spends its first and last row on `padding: 1 0`.
699
+ CONFIRM_DIALOG_COMMANDS_PADDING_HEIGHT = 2
700
+ # So this many commands fit before it starts scrolling.
701
+ CONFIRM_DIALOG_VISIBLE_COMMAND_ROWS = (
702
+ CONFIRM_DIALOG_MAX_COMMAND_ROWS - CONFIRM_DIALOG_COMMANDS_PADDING_HEIGHT
703
+ )
704
+ # `border: solid` (1 row top and bottom) plus `padding: 1 2` (1 row top and
705
+ # bottom) plus the prompt and hint rows: what the popup spends on everything
706
+ # but the commands area, vertically.
707
+ CONFIRM_DIALOG_CHROME_HEIGHT = 6
708
+ # `border: solid` (1 cell each side) plus `padding: 1 2` (2 cells each side):
709
+ # what the popup spends on chrome, i.e. how much narrower its text is than the
710
+ # box that holds it.
711
+ CONFIRM_DIALOG_CHROME_WIDTH = 6
712
+ # Width of the commands area's scrollbar. Pinned in the dialog's own CSS so
713
+ # the reservation below can't drift from what Textual actually draws.
714
+ CONFIRM_DIALOG_SCROLLBAR_WIDTH = 2
715
+ # A dialog narrower than this is unreadable no matter how short its file list
716
+ # is, so the prompt/hint set the floor rather than the widest command.
717
+ CONFIRM_DIALOG_MIN_TEXT_WIDTH = 30
718
+ # Appended in place of the cut-off tail of a line that doesn't fit the screen.
719
+ TRUNCATION_MARKER = "…"
720
+ SETTINGS_FILENAME = "settings.json"
721
+
722
+
723
+ def _strip_markup(text: str) -> str:
724
+ """`text` without its content-markup tags, i.e. what it occupies on screen."""
725
+ return re.sub(r"\[/?[^\[\]]*\]", "", text)
726
+
727
+
728
+ def _escape_markup(text: str) -> str:
729
+ """`text` rendered literally, i.e. with its markup tags neutralised.
730
+
731
+ Settings values are typed by the user and drawn into a markup-rendering
732
+ Static, so a prompt template mentioning "[files]" would otherwise be read
733
+ as a style tag and swallowed.
734
+ """
735
+ return text.replace("[", "\\[")
736
+
737
+
738
+ # One word of a settings field plus the spaces that follow it, or a run of
739
+ # spaces on its own — the units `_wrap_field_text()` moves between lines.
740
+ FIELD_WRAP_TOKEN_PATTERN = re.compile(r"\S+ *| +")
741
+
742
+
743
+ class FieldLine(NamedTuple):
744
+ """One display line of a framed field: the text on it, and where that text
745
+ starts in the value it was wrapped from.
746
+
747
+ The offset is what lets the caret be a position in the value and a cell on
748
+ the screen at the same time — `_caret_position()` maps one to the other.
749
+ """
750
+
751
+ start: int
752
+ text: str
753
+
754
+
755
+ def _wrap_field_text(text: str, *, width: int) -> list[FieldLine]:
756
+ """`text` broken at spaces into lines of at most `width` cells.
757
+
758
+ Not `textwrap.wrap()`: this text is being typed into, so the space after
759
+ a finished word has to survive — it is what pushes the caret along — and
760
+ an empty value has to come back as one empty line rather than no lines at
761
+ all, or the field would have nothing to draw. A word longer than the
762
+ field, a URL or a path, is broken where it fills the line, there being
763
+ nowhere better to break it. Existing newlines start a new line, so a
764
+ template saved with them keeps its shape.
765
+ """
766
+ lines: list[FieldLine] = []
767
+ offset = 0
768
+ for paragraph in text.split("\n"):
769
+ current = ""
770
+ start = offset
771
+ for token in FIELD_WRAP_TOKEN_PATTERN.findall(paragraph):
772
+ if current and len(current) + len(token) > width:
773
+ lines.append(FieldLine(start=start, text=current))
774
+ start += len(current)
775
+ current = token
776
+ else:
777
+ current += token
778
+ while len(current) > width:
779
+ lines.append(FieldLine(start=start, text=current[:width]))
780
+ start += width
781
+ current = current[width:]
782
+ lines.append(FieldLine(start=start, text=current))
783
+ # Past the end of this line sits the newline that ended the paragraph.
784
+ offset = start + len(current) + 1
785
+ return lines
786
+
787
+
788
+ def _caret_position(lines: list[FieldLine], *, caret: int) -> tuple[int, int]:
789
+ """Where offset `caret` falls in `lines`, as (line index, column).
790
+
791
+ The last line starting at or before the caret: on a wrap boundary the two
792
+ lines share the offset, and the caret belongs to the one it is about to
793
+ type into rather than the one it just filled.
794
+ """
795
+ row = 0
796
+ for index, line in enumerate(lines):
797
+ if line.start > caret:
798
+ break
799
+ row = index
800
+ return row, min(caret - lines[row].start, len(lines[row].text))
801
+
802
+
803
+ def _caret_markup(text: str, *, column: int, style: str) -> str:
804
+ """`text` with the cell at `column` marked as the caret.
805
+
806
+ The caret sits *on* a cell rather than after the text, which is what lets
807
+ it be positioned inside the value; past the last character it takes the
808
+ blank that follows. The style has to swap the cell's colors rather than
809
+ paint one, since it covers a character that still has to be readable.
810
+ """
811
+ head, cell, tail = text[:column], text[column : column + 1] or " ", text[column + 1 :]
812
+ return f"{_escape_markup(head)}[{style}]{_escape_markup(cell)}[/{style}]{_escape_markup(tail)}"
813
+
814
+
815
+ def _setting_str(settings: Mapping[str, object], key: str, default: str) -> str:
816
+ """`settings[key]` when it holds a string, else `default`.
817
+
818
+ Settings come off disk as JSON, so a hand-edited file can put anything at
819
+ all under any key.
820
+ """
821
+ value = settings.get(key, default)
822
+ return value if isinstance(value, str) else default
823
+
824
+
825
+ def _truncate_to_width(text: str, width: int) -> str:
826
+ """`text` cut down to `width` cells, with the cut marked as such.
827
+
828
+ A file list is only useful if the reader can tell a name they are seeing
829
+ in full from one that runs off the edge, so the marker replaces the last
830
+ cells rather than being appended past the limit.
831
+ """
832
+ if width <= 0 or len(text) <= width:
833
+ return text
834
+ if width <= len(TRUNCATION_MARKER):
835
+ return TRUNCATION_MARKER[:width]
836
+ return text[: width - len(TRUNCATION_MARKER)] + TRUNCATION_MARKER
837
+
838
+
839
+ # How much of a `git stash list` line the pop confirmation quotes. The line
840
+ # carries the branch and the commit subject the entry was made on, which is
841
+ # what tells two entries apart; the rest can go, and the dialog only sizes
842
+ # itself from the commands and the hint anyway.
843
+ STASH_PROMPT_MAX_WIDTH = 60
844
+
845
+
846
+ # What stands in for a branch name on either side of a mismatch when the
847
+ # repository is on a detached HEAD and there is no name to print.
848
+ DETACHED_HEAD_LABEL = "(detached HEAD)"
849
+
850
+
851
+ def _submodule_mismatch_text(
852
+ sub_path: str,
853
+ sub_branch: str | None,
854
+ parent_branch: str | None,
855
+ ) -> str:
856
+ """One line stating that a submodule is on a branch of its own.
857
+
858
+ Shared by the status view's warning label and the pull/push
859
+ confirmations, so the same fact can't be worded two ways depending on
860
+ where it is read.
861
+ """
862
+ sub = sub_branch if sub_branch is not None else DETACHED_HEAD_LABEL
863
+ parent = parent_branch if parent_branch is not None else DETACHED_HEAD_LABEL
864
+ return f"submodule '{sub_path}' is on branch '{sub}' but parent is on '{parent}'"
865
+
866
+
867
+ def _remote_result_summary(result: subprocess.CompletedProcess[str], *, failed: bool) -> str:
868
+ """The one line worth notifying about a finished pull or push.
869
+
870
+ Which end of the output that is depends on how it went. A failure leads
871
+ with the reason and follows it with git's hints ("! [rejected] …" and
872
+ then three lines of advice), so the first stderr line is the message. A
873
+ success ends with the outcome — "Already up to date.", "Everything
874
+ up-to-date", the ref update — so the last line of either stream is.
875
+ """
876
+ stderr = [line.strip() for line in (result.stderr or "").splitlines() if line.strip()]
877
+ if failed:
878
+ return stderr[0] if stderr else ""
879
+ stdout = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
880
+ lines = stdout + stderr
881
+ return lines[-1] if lines else ""
882
+
883
+
884
+ SCROLL_KEYS = ("up", "down", "pageup", "pagedown", "home", "end")
885
+
886
+
887
+ class SettingsValues(NamedTuple):
888
+ """What the settings dialog hands back when the user saves."""
889
+
890
+ branch_prefix: bool
891
+ draft_command: str
892
+ draft_prompt: str
893
+ default_branch: str
894
+ parse_suggestions: bool = False
895
+ editor: str = COMMIT_EDITOR_BUILTIN
896
+ editor_command: str = ""
897
+
898
+
899
+ # Rows of the settings dialog, in the order they are drawn. The checkbox rows
900
+ # are toggled with Space, and the three editor rows are a radio group Space
901
+ # picks from; every other row is a free-text field, edited a character at a
902
+ # time. The last three are the commit-drafting settings, drawn under the
903
+ # Experimental heading.
904
+ SETTINGS_ROW_BRANCH = 0
905
+ SETTINGS_ROW_PREFIX = 1
906
+ SETTINGS_ROW_EDITOR_BUILTIN = 2
907
+ SETTINGS_ROW_EDITOR_ENVIRONMENT = 3
908
+ SETTINGS_ROW_EDITOR_COMMAND = 4
909
+ SETTINGS_ROW_EDITOR_COMMAND_LINE = 5
910
+ SETTINGS_ROW_COMMAND = 6
911
+ SETTINGS_ROW_PROMPT = 7
912
+ SETTINGS_ROW_PARSE = 8
913
+ SETTINGS_ROW_COUNT = 9
914
+
915
+ # Which editor each radio row selects.
916
+ SETTINGS_EDITOR_ROWS: dict[int, str] = {
917
+ SETTINGS_ROW_EDITOR_BUILTIN: COMMIT_EDITOR_BUILTIN,
918
+ SETTINGS_ROW_EDITOR_ENVIRONMENT: COMMIT_EDITOR_ENVIRONMENT,
919
+ SETTINGS_ROW_EDITOR_COMMAND: COMMIT_EDITOR_COMMAND,
920
+ }
921
+
922
+ # Inner width of the framed field the drafting prompt is edited in. It fits
923
+ # inside `SettingsDialog`'s `min-width: 72` box with its indent and border,
924
+ # so the frame never widens the dialog. One of those cells is the space that
925
+ # keeps the text off the left border, leaving the rest for the text itself.
926
+ SETTINGS_FIELD_WIDTH = 60
927
+ SETTINGS_FIELD_TEXT_WIDTH = SETTINGS_FIELD_WIDTH - 1
928
+
929
+ # How tall that field is: this many lines when the value is shorter, then one
930
+ # line per wrapped line until the cap, past which the field scrolls. The cap
931
+ # is what keeps a pasted-in essay from pushing the rows below it off screen.
932
+ SETTINGS_FIELD_MIN_LINES = 3
933
+ SETTINGS_FIELD_MAX_LINES = 8
934
+
935
+ # How far a section heading's rule runs: to the right edge of the framed
936
+ # field, which is the widest thing the dialog draws (its four-cell indent
937
+ # plus the frame's two borders).
938
+ SETTINGS_SECTION_WIDTH = 4 + SETTINGS_FIELD_WIDTH + 2
939
+
940
+
941
+ def _dialog_theme(screen: ModalScreen) -> UITheme:
942
+ """The active UITheme, falling back when there is no live app.
943
+
944
+ Dialogs render themed markup while composing, which happens before the
945
+ screen is fully wired up in some tests.
946
+ """
947
+ try:
948
+ return screen.app._active_ui_theme() # pyright: ignore[reportAttributeAccessIssue]
949
+ except NO_SCREEN_ERRORS:
950
+ return MIDNIGHT_COMMANDER_THEME
951
+
952
+
953
+ class ConfirmDialog(ModalScreen[bool]):
954
+ """Modal confirmation dialog.
955
+
956
+ Shows a prompt, one or more git commands that will run, and a Y/N hint.
957
+ When more commands are supplied than fit in the popup, the command list
958
+ becomes scrollable.
959
+
960
+ The box is as wide as the file list it shows — the widest of the prompt,
961
+ the commands and the hint — and stops at the screen edge, where a command
962
+ too long to fit is cut with `TRUNCATION_MARKER` so a name running off the
963
+ end can be told from one shown in full.
964
+
965
+ As a ModalScreen it truncates Textual's binding chain (see
966
+ `Screen._modal_binding_chain`), so the file list underneath keeps neither
967
+ focus nor bindings — no key handled here can leak into an app action, and
968
+ no app action can re-enter while it is open.
969
+
970
+ With `fill_height` the command list is not capped at
971
+ `CONFIRM_DIALOG_VISIBLE_COMMAND_ROWS` but grows to the screen height, so a
972
+ commit's whole file list can be read without scrolling when it fits.
973
+ """
974
+
975
+ # The screen itself handles every key, so nothing inside should take
976
+ # focus; leaving this as None also stops the app's "#status_list"
977
+ # AUTO_FOCUS from being applied to this screen.
978
+ AUTO_FOCUS = None
979
+
980
+ DEFAULT_CSS = f"""
981
+ ConfirmDialog {{
982
+ align: center middle;
983
+ }}
984
+ ConfirmDialog #confirm-body {{
985
+ background: $panel;
986
+ border: solid $accent;
987
+ width: auto;
988
+ max-width: 100%;
989
+ height: auto;
990
+ padding: 1 2;
991
+ }}
992
+ ConfirmDialog Label {{
993
+ width: 100%;
994
+ height: auto;
995
+ }}
996
+ ConfirmDialog #confirm-commands {{
997
+ width: auto;
998
+ height: auto;
999
+ max-height: {CONFIRM_DIALOG_MAX_COMMAND_ROWS};
1000
+ padding: 1 0;
1001
+ background: transparent;
1002
+ scrollbar-size-vertical: {CONFIRM_DIALOG_SCROLLBAR_WIDTH};
1003
+ }}
1004
+ ConfirmDialog #confirm-commands-content {{
1005
+ width: auto;
1006
+ height: auto;
1007
+ color: $accent;
1008
+ }}
1009
+ """
1010
+
1011
+ def __init__(self, prompt: str, commands: list[str], *, fill_height: bool = False) -> None:
1012
+ super().__init__()
1013
+ self.prompt = prompt
1014
+ self.commands = list(commands)
1015
+ self.fill_height = fill_height
1016
+
1017
+ def compose(self) -> ComposeResult:
1018
+ theme = _dialog_theme(self)
1019
+ hint = (
1020
+ f"[{theme.confirm_yes_key}]Y[/]es "
1021
+ f"[{theme.confirm_no_key}]N[/]o "
1022
+ f"[{theme.dialog_hint}](↑↓ scroll, Esc cancels)[/{theme.dialog_hint}]"
1023
+ )
1024
+ text_width = self.text_width(hint=_strip_markup(hint))
1025
+ body = Vertical(id="confirm-body")
1026
+ if text_width:
1027
+ # The box is sized from its content instead of left on `width:
1028
+ # auto` so the file list decides the width up to the screen edge,
1029
+ # and so the truncation below is measured against the same number.
1030
+ body.styles.width = text_width + CONFIRM_DIALOG_CHROME_WIDTH + self.scrollbar_width()
1031
+ with body:
1032
+ yield Label(self.prompt, id="confirm-prompt")
1033
+ commands_area = ScrollableContainer(id="confirm-commands")
1034
+ if self.fill_height:
1035
+ commands_area.styles.max_height = (
1036
+ self.visible_command_rows() + CONFIRM_DIALOG_COMMANDS_PADDING_HEIGHT
1037
+ )
1038
+ with commands_area:
1039
+ yield Static(
1040
+ "\n".join(self.command_lines(text_width)),
1041
+ id="confirm-commands-content",
1042
+ )
1043
+ yield Label(hint, id="confirm-hint")
1044
+
1045
+ def command_lines(self, width: int = 0) -> list[str]:
1046
+ """The command list as rendered, truncated to `width` (0 = no limit)."""
1047
+ return [_truncate_to_width(f"$ {cmd}", width) for cmd in self.commands]
1048
+
1049
+ def text_width(self, *, hint: str = "") -> int:
1050
+ """Inner width of the popup: as wide as its widest line, but never
1051
+ wider than the screen. 0 when the screen hasn't been measured yet, in
1052
+ which case the CSS `width: auto` sizes the box instead.
1053
+ """
1054
+ available = self._available_text_width()
1055
+ if not available:
1056
+ return 0
1057
+ widest = max(len(line) for line in [self.prompt, hint, *self.command_lines()])
1058
+ return max(min(widest, available), min(CONFIRM_DIALOG_MIN_TEXT_WIDTH, available))
1059
+
1060
+ def scrollbar_width(self) -> int:
1061
+ """Cells the commands area's scrollbar will take, 0 when it won't
1062
+ scroll. Sizing the box without it would push the scrollbar past the
1063
+ border, where it is clipped and the user never sees that there is
1064
+ more of the list below.
1065
+ """
1066
+ if len(self.commands) <= self.visible_command_rows():
1067
+ return 0
1068
+ return CONFIRM_DIALOG_SCROLLBAR_WIDTH
1069
+
1070
+ def visible_command_rows(self) -> int:
1071
+ """How many commands show before the list scrolls: the CSS cap, or
1072
+ with `fill_height` whatever the screen has room for once the rest of
1073
+ the popup is drawn. An unmeasured screen falls back to the cap.
1074
+ """
1075
+ if not self.fill_height:
1076
+ return CONFIRM_DIALOG_VISIBLE_COMMAND_ROWS
1077
+ try:
1078
+ screen_height = self.app.size.height
1079
+ except NO_SCREEN_ERRORS:
1080
+ return CONFIRM_DIALOG_VISIBLE_COMMAND_ROWS
1081
+ if not screen_height:
1082
+ return CONFIRM_DIALOG_VISIBLE_COMMAND_ROWS
1083
+ room = screen_height - CONFIRM_DIALOG_CHROME_HEIGHT - CONFIRM_DIALOG_COMMANDS_PADDING_HEIGHT
1084
+ return max(room, 1)
1085
+
1086
+ def _available_text_width(self) -> int:
1087
+ """How much text the screen has room for, chrome subtracted."""
1088
+ try:
1089
+ screen_width = self.app.size.width
1090
+ except NO_SCREEN_ERRORS:
1091
+ return 0
1092
+ if not screen_width:
1093
+ return 0
1094
+ room = screen_width - CONFIRM_DIALOG_CHROME_WIDTH - self.scrollbar_width()
1095
+ return max(room, len(TRUNCATION_MARKER))
1096
+
1097
+ def on_key(self, event) -> None:
1098
+ """Answer, scroll, or swallow. Every key stops here so none reaches
1099
+ the app's own on_key while the dialog is up."""
1100
+ event.stop()
1101
+ event.prevent_default()
1102
+ key = event.key
1103
+ if key in ("y", "Y", "enter"):
1104
+ self.dismiss(True)
1105
+ elif key in ("n", "N", "escape"):
1106
+ self.dismiss(False)
1107
+ elif key in SCROLL_KEYS:
1108
+ self.scroll_commands(key)
1109
+
1110
+ def scroll_commands(self, direction: str) -> None:
1111
+ try:
1112
+ container = self.query_one("#confirm-commands", ScrollableContainer)
1113
+ except WIDGET_LOOKUP_ERRORS:
1114
+ return
1115
+ if direction == "up":
1116
+ container.scroll_up(animate=False)
1117
+ elif direction == "down":
1118
+ container.scroll_down(animate=False)
1119
+ elif direction == "pageup":
1120
+ container.scroll_page_up(animate=False)
1121
+ elif direction == "pagedown":
1122
+ container.scroll_page_down(animate=False)
1123
+ elif direction == "home":
1124
+ container.scroll_home(animate=False)
1125
+ elif direction == "end":
1126
+ container.scroll_end(animate=False)
1127
+
1128
+
1129
+ class MidnightCommanderDialog(ModalScreen[bool]):
1130
+ """Startup warning when the app runs under Midnight Commander.
1131
+
1132
+ Dismisses with True to continue and False to exit. Esc exits: the
1133
+ warning is about keys going astray, so the key that backs out of a
1134
+ question shouldn't be the one that carries on regardless.
1135
+ """
1136
+
1137
+ AUTO_FOCUS = None
1138
+
1139
+ DEFAULT_CSS = f"""
1140
+ MidnightCommanderDialog {{
1141
+ align: center middle;
1142
+ }}
1143
+ MidnightCommanderDialog #mc-warning-body {{
1144
+ background: $panel;
1145
+ border: solid $warning;
1146
+ border-title-color: $warning;
1147
+ border-title-style: bold;
1148
+ width: {MIDNIGHT_COMMANDER_DIALOG_WIDTH};
1149
+ max-width: 100%;
1150
+ height: auto;
1151
+ padding: 1 2;
1152
+ }}
1153
+ MidnightCommanderDialog Label {{
1154
+ width: 100%;
1155
+ height: auto;
1156
+ }}
1157
+ MidnightCommanderDialog #mc-warning-hint {{
1158
+ margin-top: 1;
1159
+ }}
1160
+ """
1161
+
1162
+ def __init__(self, message: str) -> None:
1163
+ super().__init__()
1164
+ self.message = message
1165
+
1166
+ def compose(self) -> ComposeResult:
1167
+ theme = _dialog_theme(self)
1168
+ hint = (
1169
+ f"[{theme.confirm_yes_key}]C[/]ontinue "
1170
+ f"[{theme.confirm_no_key}]E[/]xit "
1171
+ f"[{theme.dialog_hint}](Esc exits)[/{theme.dialog_hint}]"
1172
+ )
1173
+ body = Vertical(id="mc-warning-body")
1174
+ body.border_title = MIDNIGHT_COMMANDER_WARNING_TITLE
1175
+ with body:
1176
+ yield Label(_escape_markup(self.message), id="mc-warning-message")
1177
+ yield Label(hint, id="mc-warning-hint")
1178
+
1179
+ def on_key(self, event) -> None:
1180
+ """Answer or swallow; every key stops here, as in ConfirmDialog."""
1181
+ event.stop()
1182
+ event.prevent_default()
1183
+ key = event.key
1184
+ if key in ("c", "C", "enter"):
1185
+ self.dismiss(True)
1186
+ elif key in ("e", "E", "escape"):
1187
+ self.dismiss(False)
1188
+
1189
+
1190
+ class HelpDialog(ModalScreen[None]):
1191
+ """Modal dialog listing the app's keyboard shortcuts."""
1192
+
1193
+ AUTO_FOCUS = None
1194
+
1195
+ DEFAULT_CSS = """
1196
+ HelpDialog {
1197
+ align: center middle;
1198
+ }
1199
+ HelpDialog #help-body {
1200
+ background: $panel;
1201
+ border: solid $accent;
1202
+ width: auto;
1203
+ max-width: 90%;
1204
+ height: auto;
1205
+ padding: 1 2;
1206
+ }
1207
+ HelpDialog Label {
1208
+ width: 100%;
1209
+ height: auto;
1210
+ }
1211
+ HelpDialog #help-content {
1212
+ width: auto;
1213
+ height: auto;
1214
+ padding: 1 0;
1215
+ }
1216
+ """
1217
+
1218
+ def __init__(self, shortcuts: list[tuple[str, str, str, bool]]) -> None:
1219
+ super().__init__()
1220
+ self.shortcuts = list(shortcuts)
1221
+
1222
+ def compose(self) -> ComposeResult:
1223
+ theme = _dialog_theme(self)
1224
+ key_width = max((len(k) for k, _, _, _ in self.shortcuts), default=0)
1225
+ body = "\n".join(f"{key:<{key_width}} {desc}" for key, _, desc, _ in self.shortcuts)
1226
+ with Vertical(id="help-body"):
1227
+ yield Label("[b]Keyboard shortcuts[/b]", id="help-title")
1228
+ yield Static(body, id="help-content")
1229
+ yield Label(
1230
+ f"[{theme.dialog_hint}](Esc closes)[/{theme.dialog_hint}]",
1231
+ id="help-hint",
1232
+ )
1233
+
1234
+ def on_key(self, event) -> None:
1235
+ """Esc/Enter close; everything else is swallowed so shortcuts listed
1236
+ here can't fire while the user is reading about them."""
1237
+ event.stop()
1238
+ event.prevent_default()
1239
+ if event.key in ("escape", "enter"):
1240
+ self.dismiss(None)
1241
+
1242
+
1243
+ class SettingsDialog(ModalScreen["SettingsValues | None"]):
1244
+ """Modal settings dialog.
1245
+
1246
+ Handles all its own keys — Tab and Space included — which as a modal
1247
+ screen no longer requires fighting the StatusList underneath or Textual's
1248
+ screen-level focus-cycling binding for them.
1249
+
1250
+ Dismisses with the edited `SettingsValues` on Enter, or None on Escape.
1251
+ """
1252
+
1253
+ AUTO_FOCUS = None
1254
+
1255
+ DEFAULT_CSS = """
1256
+ SettingsDialog {
1257
+ align: center middle;
1258
+ }
1259
+ SettingsDialog #settings-body {
1260
+ background: $panel;
1261
+ border: solid $accent;
1262
+ width: auto;
1263
+ /* Wide enough for the framed prompt field and the section rules
1264
+ beside it (`SETTINGS_SECTION_WIDTH` plus this border and padding),
1265
+ which are the widest rows here — a narrower box wraps them, and
1266
+ `width: auto` can't grow past the widest child because that child
1267
+ is the full-width Static below. */
1268
+ min-width: 72;
1269
+ max-width: 90%;
1270
+ height: auto;
1271
+ padding: 1 2;
1272
+ }
1273
+ SettingsDialog Static {
1274
+ width: 100%;
1275
+ height: auto;
1276
+ }
1277
+ """
1278
+
1279
+ # Which attribute each free-text row edits. Everything the key handler
1280
+ # and the renderer do to those rows is the same for all of them, so the
1281
+ # rows differ only by their label and the value they carry.
1282
+ TEXT_ROWS: ClassVar[dict[int, str]] = {
1283
+ SETTINGS_ROW_BRANCH: "_default_branch",
1284
+ SETTINGS_ROW_EDITOR_COMMAND_LINE: "_editor_command",
1285
+ SETTINGS_ROW_COMMAND: "_draft_command",
1286
+ SETTINGS_ROW_PROMPT: "_draft_prompt",
1287
+ }
1288
+
1289
+ # The text rows drawn as a framed field rather than a value after a
1290
+ # label. Being several lines tall is what makes up and down the caret's
1291
+ # keys there instead of the row cursor's.
1292
+ FIELD_ROWS: ClassVar[frozenset[int]] = frozenset({SETTINGS_ROW_PROMPT})
1293
+
1294
+ def __init__(
1295
+ self,
1296
+ branch_prefix: bool = False,
1297
+ draft_command: str = COMMIT_DRAFT_COMMAND_DEFAULT,
1298
+ draft_prompt: str = COMMIT_DRAFT_PROMPT_DEFAULT,
1299
+ default_branch: str = DEFAULT_BRANCH_DEFAULT,
1300
+ parse_suggestions: bool = False,
1301
+ editor: str = COMMIT_EDITOR_BUILTIN,
1302
+ editor_command: str = "",
1303
+ ) -> None:
1304
+ super().__init__()
1305
+ self._cursor: int = 0
1306
+ self._default_branch: str = default_branch
1307
+ self._branch_prefix: bool = branch_prefix
1308
+ self._draft_command: str = draft_command
1309
+ self._draft_prompt: str = draft_prompt
1310
+ self._parse_suggestions: bool = parse_suggestions
1311
+ self._editor: str = editor if editor in COMMIT_EDITOR_MODES else COMMIT_EDITOR_BUILTIN
1312
+ self._editor_command: str = editor_command
1313
+ # Where typing goes in the row under the cursor. A row is entered
1314
+ # with the caret after its last character, which is where a value
1315
+ # that is only ever appended to would have left it.
1316
+ self._caret: int = len(default_branch)
1317
+
1318
+ def compose(self) -> ComposeResult:
1319
+ with Vertical(id="settings-body"):
1320
+ yield Static("", id="settings-content")
1321
+
1322
+ def on_mount(self) -> None:
1323
+ self._redraw()
1324
+
1325
+ def on_key(self, event) -> None:
1326
+ """Every key the dialog receives, routed by the row under the cursor.
1327
+
1328
+ Tab and Enter are what leave a row — Tab for the next one, Enter to
1329
+ save — so inside a framed field the arrows are free to be the caret's
1330
+ and never move off it. A one-line row has nowhere for up and down to
1331
+ go, so there they stay what they have always been: the row above and
1332
+ the row below.
1333
+ """
1334
+ key = event.key
1335
+ event.stop()
1336
+ event.prevent_default()
1337
+ if key == "shift+tab":
1338
+ self.move_cursor(-1)
1339
+ elif key == "tab":
1340
+ self.move_cursor(1)
1341
+ elif key == "enter":
1342
+ self.dismiss(self.get_values())
1343
+ elif key == "escape":
1344
+ self.dismiss(None)
1345
+ elif key in ("left", "right") and self._cursor in self.TEXT_ROWS:
1346
+ self.move_caret(-1 if key == "left" else 1)
1347
+ elif key in ("up", "down"):
1348
+ delta = -1 if key == "up" else 1
1349
+ if self._cursor in self.FIELD_ROWS:
1350
+ self.move_caret_line(delta)
1351
+ else:
1352
+ self.move_cursor(delta)
1353
+ elif self._cursor in self.TEXT_ROWS:
1354
+ self.edit_text(event)
1355
+ elif key == "space":
1356
+ self.toggle_current()
1357
+
1358
+ def edit_text(self, event) -> None:
1359
+ """Apply one keystroke to the free-text row under the cursor.
1360
+
1361
+ At the caret rather than at the end, since the caret is no longer
1362
+ always there — backspace takes the character in front of it, and
1363
+ anything typed pushes it along.
1364
+ """
1365
+ attribute = self.TEXT_ROWS[self._cursor]
1366
+ value: str = getattr(self, attribute)
1367
+ caret = self._clamped_caret(value)
1368
+ if event.key == "backspace":
1369
+ if not caret:
1370
+ return
1371
+ setattr(self, attribute, value[: caret - 1] + value[caret:])
1372
+ self._caret = caret - 1
1373
+ else:
1374
+ character = " " if event.key == "space" else getattr(event, "character", None)
1375
+ if not (isinstance(character, str) and character.isprintable() and len(character) == 1):
1376
+ return
1377
+ setattr(self, attribute, value[:caret] + character + value[caret:])
1378
+ self._caret = caret + 1
1379
+ if self._cursor == SETTINGS_ROW_EDITOR_COMMAND_LINE:
1380
+ # Typing a command is choosing to use it; making the user go back
1381
+ # up and pick the radio as well would only be a way to forget to.
1382
+ self._editor = COMMIT_EDITOR_COMMAND
1383
+ self._redraw()
1384
+
1385
+ def _row_value(self) -> str:
1386
+ """The text of the row under the cursor, or "" on a checkbox row."""
1387
+ attribute = self.TEXT_ROWS.get(self._cursor)
1388
+ return getattr(self, attribute) if attribute is not None else ""
1389
+
1390
+ def _clamped_caret(self, value: str) -> int:
1391
+ """The caret held inside `value`, which a row switch can leave it
1392
+ outside of until the next move."""
1393
+ return max(0, min(self._caret, len(value)))
1394
+
1395
+ def move_caret(self, delta: int) -> None:
1396
+ """Move the caret one cell along the row being edited."""
1397
+ value = self._row_value()
1398
+ self._caret = max(0, min(len(value), self._clamped_caret(value) + delta))
1399
+ self._redraw()
1400
+
1401
+ def move_caret_line(self, delta: int) -> None:
1402
+ """Move the caret one display line up or down inside a framed field.
1403
+
1404
+ It keeps its column where the target line is long enough to hold it.
1405
+ The arrows never leave the field — Tab and Enter do that — so up from
1406
+ the first line and down from the last go to the two ends of the value
1407
+ instead of to the row above or below.
1408
+ """
1409
+ value = self._row_value()
1410
+ lines = _wrap_field_text(value, width=SETTINGS_FIELD_TEXT_WIDTH)
1411
+ row, column = _caret_position(lines, caret=self._clamped_caret(value))
1412
+ target = row + delta
1413
+ if target < 0:
1414
+ self._caret = 0
1415
+ elif target >= len(lines):
1416
+ self._caret = len(value)
1417
+ else:
1418
+ self._caret = lines[target].start + min(column, len(lines[target].text))
1419
+ self._redraw()
1420
+
1421
+ def _text_row(self, row: int, label: str) -> str:
1422
+ """One free-text row: cursor arrow, label, value, and the caret on the
1423
+ row being edited."""
1424
+ value: str = getattr(self, self.TEXT_ROWS[row])
1425
+ if self._cursor != row:
1426
+ return f" {label}{_escape_markup(value)}"
1427
+ caret = _caret_markup(
1428
+ value, column=self._clamped_caret(value), style=_dialog_theme(self).text_caret
1429
+ )
1430
+ return f"> {label}{caret}"
1431
+
1432
+ def _section_header(self, title: str) -> str:
1433
+ """A heading with a rule running out to the width of the widest row,
1434
+ so the settings under it read as a group rather than as more of the
1435
+ list above."""
1436
+ theme = _dialog_theme(self)
1437
+ rule = "─" * max(0, SETTINGS_SECTION_WIDTH - len(title) - 1)
1438
+ return f"[b]{title}[/b] [{theme.dialog_hint}]{rule}[/{theme.dialog_hint}]"
1439
+
1440
+ def _field_rows(self, row: int, label: str) -> list[str]:
1441
+ """A free-text row drawn as a framed input field, label above it.
1442
+
1443
+ The drafting prompt is a paragraph, not a word, so it gets a field of
1444
+ its own instead of a value trailing its label: the frame marks where
1445
+ the text is whether or not the row is under the cursor, and the value
1446
+ wraps at word boundaries across as many lines as it needs rather than
1447
+ scrolling through one — a template is read as a whole, and its tail
1448
+ alone says little about what it asks for.
1449
+
1450
+ The field is `SETTINGS_FIELD_MIN_LINES` tall while the value is
1451
+ shorter than that and grows with it up to `SETTINGS_FIELD_MAX_LINES`;
1452
+ only past the cap does it scroll, far enough to keep the caret's line
1453
+ on screen wherever the arrows have put it.
1454
+ """
1455
+ theme = _dialog_theme(self)
1456
+ value: str = getattr(self, self.TEXT_ROWS[row])
1457
+ focused = self._cursor == row
1458
+ width = SETTINGS_FIELD_TEXT_WIDTH
1459
+ lines = _wrap_field_text(value, width=width)
1460
+ caret = self._clamped_caret(value) if focused else None
1461
+ if caret is not None:
1462
+ if caret == len(value) and len(lines[-1].text) >= width:
1463
+ # Nothing to sit on: the value ends in the last cell of a
1464
+ # full line, so the caret takes the first cell of the next.
1465
+ lines.append(FieldLine(start=caret, text=""))
1466
+ caret_line, caret_column = _caret_position(lines, caret=caret)
1467
+ else:
1468
+ caret_line, caret_column = -1, -1
1469
+ height = min(max(len(lines), SETTINGS_FIELD_MIN_LINES), SETTINGS_FIELD_MAX_LINES)
1470
+ # Short of the height the blank lines go under the text; over it the
1471
+ # last lines are the ones kept, unless the caret is above them.
1472
+ top = max(0, len(lines) - height)
1473
+ if 0 <= caret_line < top:
1474
+ top = caret_line
1475
+ visible = [line.text for line in lines[top : top + height]]
1476
+ visible += [""] * (height - len(visible))
1477
+ rule = "─" * SETTINGS_FIELD_WIDTH
1478
+ arrow = ">" if focused else " "
1479
+ hint = theme.dialog_hint
1480
+ rows = [f"{arrow} {label}", f"[{hint}] ┌{rule}┐[/{hint}]"]
1481
+ for index, line in enumerate(visible):
1482
+ on_caret_line = index + top == caret_line
1483
+ body = (
1484
+ _caret_markup(line, column=caret_column, style=theme.text_caret)
1485
+ if on_caret_line
1486
+ else _escape_markup(line)
1487
+ )
1488
+ # The caret takes a cell of its own past the end of its line.
1489
+ filled = max(len(line), caret_column + 1) if on_caret_line else len(line)
1490
+ rows.append(f"[{hint}] │[/{hint}] {body}{' ' * (width - filled)}[{hint}]│[/{hint}]")
1491
+ rows.append(f"[{hint}] └{rule}┘[/{hint}]")
1492
+ return rows
1493
+
1494
+ def _checkbox_row(self, row: int, checked: bool, label: str) -> str:
1495
+ """One checkbox row: cursor arrow, `[x]` / `[ ]`, and its label."""
1496
+ theme = _dialog_theme(self)
1497
+ mark = f"[{theme.checkbox_mark}]\\[x][/{theme.checkbox_mark}]" if checked else "\\[ ]"
1498
+ arrow = ">" if self._cursor == row else " "
1499
+ return f"{arrow} {mark} {label}"
1500
+
1501
+ def _radio_row(self, row: int, label: str) -> str:
1502
+ """One row of the editor radio group: cursor arrow, `(•)` / `( )`, and
1503
+ its label."""
1504
+ theme = _dialog_theme(self)
1505
+ selected = self._editor == SETTINGS_EDITOR_ROWS[row]
1506
+ mark = f"[{theme.checkbox_mark}](•)[/{theme.checkbox_mark}]" if selected else "( )"
1507
+ arrow = ">" if self._cursor == row else " "
1508
+ return f"{arrow} {mark} {label}"
1509
+
1510
+ def _redraw(self) -> None:
1511
+ theme = _dialog_theme(self)
1512
+ lines: list[str] = ["[b]Settings[/b]", ""]
1513
+ lines.append(self._text_row(SETTINGS_ROW_BRANCH, "Default branch: "))
1514
+ lines.append("")
1515
+ lines.append(
1516
+ self._checkbox_row(
1517
+ SETTINGS_ROW_PREFIX,
1518
+ self._branch_prefix,
1519
+ "Use branch name as prefix in commit messages",
1520
+ )
1521
+ )
1522
+ lines.append("")
1523
+ lines.append(self._section_header("Commit message editor"))
1524
+ lines.append("")
1525
+ lines.append(self._radio_row(SETTINGS_ROW_EDITOR_BUILTIN, "Built-in editor"))
1526
+ environment = os.environ.get("EDITOR", "").strip()
1527
+ lines.append(
1528
+ self._radio_row(
1529
+ SETTINGS_ROW_EDITOR_ENVIRONMENT,
1530
+ f"$EDITOR ({_escape_markup(environment)})" if environment else "$EDITOR (not set)",
1531
+ )
1532
+ )
1533
+ lines.append(self._radio_row(SETTINGS_ROW_EDITOR_COMMAND, "Other editor:"))
1534
+ lines.append(self._text_row(SETTINGS_ROW_EDITOR_COMMAND_LINE, " Command: "))
1535
+ lines.append(
1536
+ f"[{theme.dialog_hint}]"
1537
+ " (an external editor opens the message in a temporary file)"
1538
+ f"[/{theme.dialog_hint}]"
1539
+ )
1540
+ lines.append("")
1541
+ lines.append(self._section_header("Experimental"))
1542
+ lines.append("")
1543
+ lines.append(" Tool for drafting commit messages:")
1544
+ lines.append(self._text_row(SETTINGS_ROW_COMMAND, " Command: "))
1545
+ lines.extend(self._field_rows(SETTINGS_ROW_PROMPT, " Prompt:"))
1546
+ lines.append(
1547
+ f"[{theme.dialog_hint}]"
1548
+ ' (run as: command "prompt + the files being committed")'
1549
+ f"[/{theme.dialog_hint}]"
1550
+ )
1551
+ lines.append("")
1552
+ lines.append(
1553
+ self._checkbox_row(
1554
+ SETTINGS_ROW_PARSE,
1555
+ self._parse_suggestions,
1556
+ "Parse suggestions from numbered list",
1557
+ )
1558
+ )
1559
+ lines.append("")
1560
+ lines.append(f"[{theme.dialog_hint}]{self._key_hint()}[/{theme.dialog_hint}]")
1561
+ try:
1562
+ self.query_one("#settings-content", Static).update("\n".join(lines))
1563
+ except WIDGET_LOOKUP_ERRORS:
1564
+ # Keys can arrive before compose() has run under test stubs; the
1565
+ # values are already recorded, so there is simply nothing to paint.
1566
+ pass
1567
+
1568
+ def _key_hint(self) -> str:
1569
+ """The key line under the dialog, which is not the same in a framed
1570
+ field: the arrows belong to the caret there, and Tab is what leaves."""
1571
+ if self._cursor in self.FIELD_ROWS:
1572
+ return "(←→↑↓ move the caret, Tab leaves, Enter save, Esc cancel)"
1573
+ return "(↑↓/Tab move, Space toggle, Enter save, Esc cancel)"
1574
+
1575
+ def move_cursor(self, delta: int) -> None:
1576
+ self._cursor = (self._cursor + delta) % SETTINGS_ROW_COUNT
1577
+ # The row is entered with its caret at the end, the same place the
1578
+ # dialog started with and the only one that needs no arrow keys.
1579
+ self._caret = len(self._row_value())
1580
+ self._redraw()
1581
+
1582
+ def toggle_current(self) -> None:
1583
+ if self._cursor == SETTINGS_ROW_PREFIX:
1584
+ self._branch_prefix = not self._branch_prefix
1585
+ elif self._cursor == SETTINGS_ROW_PARSE:
1586
+ self._parse_suggestions = not self._parse_suggestions
1587
+ elif self._cursor in SETTINGS_EDITOR_ROWS:
1588
+ self._editor = SETTINGS_EDITOR_ROWS[self._cursor]
1589
+ self._redraw()
1590
+
1591
+ def get_values(self) -> SettingsValues:
1592
+ return SettingsValues(
1593
+ branch_prefix=self._branch_prefix,
1594
+ draft_command=self._draft_command,
1595
+ draft_prompt=self._draft_prompt,
1596
+ default_branch=self._default_branch,
1597
+ parse_suggestions=self._parse_suggestions,
1598
+ editor=self._editor,
1599
+ editor_command=self._editor_command,
1600
+ )
1601
+
1602
+
1603
+ class DraftResult(NamedTuple):
1604
+ """What the drafting tool produced. `ok` says whether `text` is a commit
1605
+ message or the reason there isn't one."""
1606
+
1607
+ ok: bool
1608
+ text: str
1609
+
1610
+
1611
+ # A line that opens a numbered suggestion: "1. feat: x", " 2) feat: x",
1612
+ # "3: feat: x". The number is dropped; group 1 is the message it introduces.
1613
+ NUMBERED_SUGGESTION_PATTERN = re.compile(r"^\s{0,3}\d{1,2}[.):]\s+(\S.*)$")
1614
+
1615
+ # The Markdown a drafting tool wraps its suggestions in: bold markers and
1616
+ # code spans, removed from the text, and the fence around a code block, whose
1617
+ # line is dropped whole. `_` is deliberately not here — it belongs to names
1618
+ # like `__init__` or `test_app.py` far more often than it marks emphasis.
1619
+ MARKDOWN_DELIMITER_PATTERN = re.compile(r"\*\*|`+")
1620
+ CODE_FENCE_PATTERN = re.compile(r"^\s*(?:```+|~~~+)\s*\S*\s*$")
1621
+
1622
+
1623
+ def parse_numbered_suggestions(text: str) -> list[str]:
1624
+ """The numbered items in `text`, without their numbers.
1625
+
1626
+ A drafting tool asked for several candidate messages answers with a
1627
+ numbered list, usually wrapped in a sentence of its own before and after.
1628
+ Anything ahead of the first number is dropped; an item then keeps the
1629
+ blank and *indented* lines under it, which is how a suggestion with a
1630
+ body survives, and ends at the first line back in column 0 that isn't
1631
+ another number. That last part is what keeps the tool's closing remarks —
1632
+ "My pick: #2, because…" — out of the message the user picked: they are
1633
+ prose about the list, not part of the item they happen to follow.
1634
+
1635
+ Only the item is ended, not the parse, so a tool that comments between
1636
+ its suggestions still yields all of them. The cost is a body written
1637
+ without indentation, which reads as the end of its item; the pick is
1638
+ editable before F2 either way, which is the remedy for that and for a
1639
+ tool that numbers something other than a message.
1640
+ """
1641
+ suggestions: list[list[str]] = []
1642
+ continuing = False
1643
+ for line in text.splitlines():
1644
+ match = NUMBERED_SUGGESTION_PATTERN.match(line)
1645
+ if match is not None:
1646
+ suggestions.append([match.group(1).strip()])
1647
+ continuing = True
1648
+ elif not continuing:
1649
+ continue
1650
+ elif line.strip() and not line.startswith((" ", "\t")):
1651
+ continuing = False
1652
+ else:
1653
+ suggestions[-1].append(line)
1654
+ return [joined for item in suggestions if (joined := _joined_suggestion(item))]
1655
+
1656
+
1657
+ def _strip_markdown_delimiters(lines: list[str]) -> list[str]:
1658
+ """The item's lines with the markup a tool decorated them with taken off.
1659
+
1660
+ A drafting tool writing a list writes it as Markdown — `**bold**` around
1661
+ the subject, backticks around the message or a filename in it, a fenced
1662
+ block around the whole thing — and a commit message is plain text, so the
1663
+ markers come off before the suggestion reaches the message box. A fence
1664
+ line goes entirely, because stripping its backticks would leave its
1665
+ language tag behind as a line of the message. The rest are removed marker
1666
+ by marker rather than in matched pairs: an item cut off at the first
1667
+ unindented line can end mid-emphasis, and a stray `**` in the message is
1668
+ exactly what this is here to prevent.
1669
+ """
1670
+ return [
1671
+ MARKDOWN_DELIMITER_PATTERN.sub("", line)
1672
+ for line in lines
1673
+ if CODE_FENCE_PATTERN.match(line) is None
1674
+ ]
1675
+
1676
+
1677
+ def _joined_suggestion(lines: list[str]) -> str:
1678
+ """One parsed item as a commit message: the subject, then its body with
1679
+ the list indentation taken back off — that indent is what marked the
1680
+ lines as belonging to the item, not something the message asked for."""
1681
+ stripped = _strip_markdown_delimiters(lines)
1682
+ if not stripped:
1683
+ return ""
1684
+ subject, *body = stripped
1685
+ if not body:
1686
+ return subject.strip()
1687
+ dedented = textwrap.dedent("\n".join(body))
1688
+ return f"{subject}\n{dedented}".strip()
1689
+
1690
+
1691
+ def _commit_message_subject(message: str) -> str:
1692
+ """The message's first line, marked as cut when there is more below it.
1693
+
1694
+ The confirmation popup renders one command per row, so the `-m` argument
1695
+ shown there has to be a single line.
1696
+ """
1697
+ lines = message.strip().split("\n")
1698
+ if len(lines) == 1:
1699
+ return lines[0]
1700
+ return f"{lines[0]}{TRUNCATION_MARKER}"
1701
+
1702
+
1703
+ class EditorResult(NamedTuple):
1704
+ """What an external editor left behind.
1705
+
1706
+ `started` is False when the editor couldn't be run at all, and `detail`
1707
+ says why. Otherwise `message` is the edited message — or None when the
1708
+ editor exited with a failure status, which is how vim's `:cq` and its
1709
+ kind say "abandon this", and `detail` then carries that status.
1710
+ """
1711
+
1712
+ started: bool
1713
+ message: str | None
1714
+ detail: str = ""
1715
+
1716
+
1717
+ def commit_editor_template(message: str, filenames: list[str]) -> str:
1718
+ """The temporary file handed to the editor: the message so far, then
1719
+ git's scissors line and, under it, the help text and the files going into
1720
+ the commit — which is what the built-in dialog can't show and the editor
1721
+ has room for."""
1722
+ help_lines = [
1723
+ COMMIT_EDITOR_SCISSORS,
1724
+ "# Do not modify or remove the line above.",
1725
+ "# Everything below it is ignored. An empty message cancels the commit.",
1726
+ "#",
1727
+ "# Files in this commit:",
1728
+ *(f"#\t{filename}" for filename in filenames),
1729
+ ]
1730
+ return "\n".join([message, "", *help_lines, ""])
1731
+
1732
+
1733
+ def strip_commit_editor_text(text: str) -> str:
1734
+ """The message in an edited `commit_editor_template()`: everything above
1735
+ the scissors line, with trailing whitespace and the blank lines around it
1736
+ taken off. A file whose scissors line was deleted is kept whole, as git
1737
+ keeps it."""
1738
+ lines = text.splitlines()
1739
+ if COMMIT_EDITOR_SCISSORS in lines:
1740
+ lines = lines[: lines.index(COMMIT_EDITOR_SCISSORS)]
1741
+ return "\n".join(line.rstrip() for line in lines).strip("\n")
1742
+
1743
+
1744
+ def run_commit_editor(
1745
+ *, argv: list[str], message: str, filenames: list[str], cwd: str
1746
+ ) -> EditorResult:
1747
+ """Edit `message` in the editor `argv` names and return what it saved.
1748
+
1749
+ The message goes to a temporary `COMMIT_EDITMSG` and the editor is run
1750
+ as `<argv> <path>`, on the terminal as it is — the caller has suspended
1751
+ the app, so the editor owns the screen and the keyboard until it exits.
1752
+ The directory the file sits in is removed again whatever happens.
1753
+
1754
+ Ctrl+C inside an editor that doesn't claim it reaches this process too;
1755
+ it is read as the editor being abandoned rather than let out, because an
1756
+ exception escaping here would skip `App.suspend()`'s resume.
1757
+ """
1758
+ with tempfile.TemporaryDirectory(prefix="gitnc-") as directory:
1759
+ path = Path(directory) / COMMIT_EDITOR_FILENAME
1760
+ path.write_text(commit_editor_template(message, filenames), encoding="utf-8")
1761
+ try:
1762
+ completed = subprocess.run([*argv, str(path)], cwd=cwd, check=False)
1763
+ except OSError as exc:
1764
+ return EditorResult(
1765
+ started=False, message=None, detail=f"Could not run {argv[0]}: {exc}"
1766
+ )
1767
+ except KeyboardInterrupt:
1768
+ return EditorResult(started=True, message=None, detail=f"{argv[0]} was interrupted")
1769
+ if completed.returncode != 0:
1770
+ return EditorResult(
1771
+ started=True,
1772
+ message=None,
1773
+ detail=f"{argv[0]} exited with status {completed.returncode}",
1774
+ )
1775
+ try:
1776
+ edited = path.read_text(encoding="utf-8")
1777
+ except (OSError, UnicodeDecodeError) as exc:
1778
+ return EditorResult(
1779
+ started=True, message=None, detail=f"Could not read the message back: {exc}"
1780
+ )
1781
+ return EditorResult(started=True, message=strip_commit_editor_text(edited))
1782
+
1783
+
1784
+ def commit_draft_prompt(template: str, filenames: list[str]) -> str:
1785
+ """The prompt handed to the drafting tool: the template from the
1786
+ settings, then the files going into the commit, one per line."""
1787
+ return "\n".join([template.rstrip(), *filenames])
1788
+
1789
+
1790
+ def _pump_draft_output(
1791
+ *,
1792
+ stream: IO[str] | None,
1793
+ sink: list[str],
1794
+ on_output: Callable[[str], None] | None,
1795
+ ) -> None:
1796
+ """Read `stream` a line at a time into `sink`, reporting each line.
1797
+
1798
+ Line at a time rather than `.read()` is the whole point: the caller shows
1799
+ the tool's progress while it is still running. The SGR strip matches the
1800
+ one `GIT` does — the lines are rendered literally.
1801
+ """
1802
+ if stream is None:
1803
+ return
1804
+ with stream:
1805
+ for raw in stream:
1806
+ line = ANSI_SGR_PATTERN.sub("", raw).rstrip("\r\n")
1807
+ sink.append(line)
1808
+ if on_output is not None:
1809
+ on_output(line)
1810
+
1811
+
1812
+ def run_commit_draft(
1813
+ *,
1814
+ argv: list[str],
1815
+ prompt: str,
1816
+ cwd: str,
1817
+ on_output: Callable[[str], None] | None = None,
1818
+ ) -> DraftResult:
1819
+ """Run the drafting tool and return its output as a commit message.
1820
+
1821
+ Called from a thread worker (see `GitNightCommanderApp._commit_draft_worker`), so it
1822
+ touches no widget and no app state; `on_output` is how the caller gets
1823
+ each line of stdout and stderr as it is produced, so a tool that takes
1824
+ minutes can show what it is doing rather than only that it is busy. That
1825
+ is why this is `Popen` and two pumps instead of `subprocess.run`: the
1826
+ latter hands its output over only once the process has exited.
1827
+
1828
+ stdout is the commit message and stderr is the failure detail, so the two
1829
+ are kept apart even though both are reported as progress. stderr gets its
1830
+ own reader thread — reading them in sequence would deadlock as soon as
1831
+ one filled its pipe buffer while the other was being drained.
1832
+
1833
+ The tool's stdin is closed: the run shows on the previous screen, but it
1834
+ is not an interactive session — a tool that decides to prompt would sit
1835
+ there waiting for a terminal nobody is typing into.
1836
+ """
1837
+ try:
1838
+ process = subprocess.Popen(
1839
+ [*argv, prompt],
1840
+ cwd=cwd,
1841
+ stdin=subprocess.DEVNULL,
1842
+ stdout=subprocess.PIPE,
1843
+ stderr=subprocess.PIPE,
1844
+ text=True,
1845
+ bufsize=1,
1846
+ )
1847
+ except OSError as exc:
1848
+ return DraftResult(ok=False, text=f"Could not run {argv[0]}: {exc}")
1849
+
1850
+ out_lines: list[str] = []
1851
+ err_lines: list[str] = []
1852
+ # `subprocess.run(timeout=...)` used to enforce this; with the pumps
1853
+ # blocking on the pipes instead, a watchdog kills the process and the
1854
+ # pumps end at EOF.
1855
+ timed_out = threading.Event()
1856
+
1857
+ def give_up() -> None:
1858
+ timed_out.set()
1859
+ process.kill()
1860
+
1861
+ watchdog = threading.Timer(COMMIT_DRAFT_TIMEOUT_SECONDS, give_up)
1862
+ watchdog.daemon = True
1863
+ errors = threading.Thread(
1864
+ target=_pump_draft_output,
1865
+ kwargs={"stream": process.stderr, "sink": err_lines, "on_output": on_output},
1866
+ daemon=True,
1867
+ )
1868
+ watchdog.start()
1869
+ errors.start()
1870
+ interrupted = False
1871
+ try:
1872
+ _pump_draft_output(stream=process.stdout, sink=out_lines, on_output=on_output)
1873
+ process.wait()
1874
+ except KeyboardInterrupt:
1875
+ # Ctrl+C while the run is on the previous screen: the app is
1876
+ # suspended, so the terminal is back in cooked mode and the interrupt
1877
+ # arrives here rather than in the TUI. It reaches the tool too (same
1878
+ # process group), so the kill is only for a tool that ignored it.
1879
+ # Letting it escape would skip `App.suspend()`'s resume and leave the
1880
+ # terminal outside the alternate screen.
1881
+ interrupted = True
1882
+ process.kill()
1883
+ process.wait()
1884
+ finally:
1885
+ watchdog.cancel()
1886
+ errors.join(timeout=COMMIT_DRAFT_JOIN_SECONDS)
1887
+
1888
+ if interrupted:
1889
+ return DraftResult(ok=False, text=f"{argv[0]} cancelled")
1890
+ if timed_out.is_set():
1891
+ return DraftResult(
1892
+ ok=False, text=f"{argv[0]} timed out after {COMMIT_DRAFT_TIMEOUT_SECONDS}s"
1893
+ )
1894
+ if process.returncode != 0:
1895
+ detail = [line for line in err_lines if line.strip()]
1896
+ suffix = f": {detail[-1]}" if detail else ""
1897
+ return DraftResult(ok=False, text=f"{argv[0]} failed{suffix}")
1898
+ message = "\n".join(out_lines).strip()
1899
+ if not message:
1900
+ return DraftResult(ok=False, text=f"{argv[0]} produced no commit message")
1901
+ return DraftResult(ok=True, text=message)
1902
+
1903
+
1904
+ class CommitTextArea(TextArea):
1905
+ """TextArea that surrenders F2 / F4 / Esc to the commit dialog flow.
1906
+
1907
+ Why: TextArea must own most keys so the user can type a multi-line
1908
+ message. We only peel off the keys that submit, draft or cancel, and
1909
+ delegate those back to the owning dialog.
1910
+
1911
+ F2 is the Norton Commander / MC "save" key, matching the F9/F10 bindings
1912
+ this app already uses, and function keys survive terminal flow control
1913
+ and multiplexers intact.
1914
+ """
1915
+
1916
+ BINDINGS: ClassVar[list[Binding]] = [
1917
+ Binding("f2", "commit_submit", "Commit", show=False),
1918
+ Binding("f4", "commit_draft", "Draft", show=False),
1919
+ Binding("escape", "commit_cancel", "Cancel", show=False),
1920
+ ]
1921
+
1922
+ def _dialog_action(self, name: str) -> None:
1923
+ """Forward to the owning CommitDialog screen.
1924
+
1925
+ Resolved by name rather than isinstance so this keeps working under
1926
+ the test stubs, where the screen is a mock.
1927
+ """
1928
+ handler = getattr(self.screen, name, None)
1929
+ if callable(handler):
1930
+ handler()
1931
+
1932
+ def action_commit_submit(self) -> None:
1933
+ self._dialog_action("submit")
1934
+
1935
+ def action_commit_draft(self) -> None:
1936
+ self._dialog_action("draft")
1937
+
1938
+ def action_commit_cancel(self) -> None:
1939
+ self._dialog_action("cancel")
1940
+
1941
+
1942
+ class CommitDialog(ModalScreen["str | None"]):
1943
+ """Modal dialog that captures a multi-line commit message.
1944
+
1945
+ Dismisses with the message on F2, or None on Esc. F4 — the Draft button —
1946
+ asks the app to run the configured drafting tool and drops its output
1947
+ into the text area, where it stays as editable as anything typed by hand.
1948
+ That run happens on the previous screen, which is where everything the
1949
+ tool prints is shown; the log pane under the buttons is for the terminal
1950
+ that can't be suspended, where it is the only way to tell a tool still
1951
+ working from one that has hung.
1952
+
1953
+ The box fills the screen, both ways: a commit message is prose the user
1954
+ may want to see whole, so the dialog is the largest thing the app puts
1955
+ on screen rather than a popup measured against its content. The text
1956
+ area takes every row the title, buttons, log pane and hint leave over,
1957
+ and scrolls once the message runs past them.
1958
+
1959
+ Unlike the other dialogs this one carries BINDINGS as well as
1960
+ CommitTextArea's copy of them. It has no `on_key` stopping events, so the
1961
+ screen's bindings are reached normally — which is what makes F2/F4/Esc
1962
+ work while the Draft button, the other focusable widget here, has focus.
1963
+ """
1964
+
1965
+ AUTO_FOCUS = "#commit-message"
1966
+
1967
+ BINDINGS: ClassVar[list[Binding]] = [
1968
+ Binding("f2", "commit_submit", "Commit", show=False),
1969
+ Binding("f4", "commit_draft", "Draft", show=False),
1970
+ Binding("escape", "commit_cancel", "Cancel", show=False),
1971
+ ]
1972
+
1973
+ DEFAULT_CSS = f"""
1974
+ CommitDialog {{
1975
+ align: center middle;
1976
+ }}
1977
+ CommitDialog #commit-body {{
1978
+ background: $panel;
1979
+ border: solid $accent;
1980
+ width: 100%;
1981
+ height: 100%;
1982
+ padding: 1 2;
1983
+ }}
1984
+ CommitDialog Label {{
1985
+ width: 100%;
1986
+ height: auto;
1987
+ }}
1988
+ CommitDialog #commit-message {{
1989
+ width: 100%;
1990
+ /* The rows nothing else needs; a longer message scrolls inside. */
1991
+ height: 1fr;
1992
+ min-height: 3;
1993
+ border: solid $accent;
1994
+ }}
1995
+ CommitDialog #commit-buttons {{
1996
+ width: 100%;
1997
+ height: auto;
1998
+ align-horizontal: left;
1999
+ }}
2000
+ CommitDialog #commit-draft {{
2001
+ margin: 0 2 0 0;
2002
+ }}
2003
+ CommitDialog #commit-status {{
2004
+ /* Buttons are three rows tall; centre the note against the label
2005
+ inside the one next to it. */
2006
+ height: 3;
2007
+ content-align-vertical: middle;
2008
+ }}
2009
+ CommitDialog #commit-output {{
2010
+ width: 100%;
2011
+ height: {COMMIT_DRAFT_OUTPUT_ROWS};
2012
+ border: solid $accent;
2013
+ background: transparent;
2014
+ /* Nothing to show until the Draft button runs something. */
2015
+ display: none;
2016
+ }}
2017
+ CommitDialog #commit-output-content {{
2018
+ width: auto;
2019
+ height: auto;
2020
+ }}
2021
+ """
2022
+
2023
+ def __init__(self, prefill: str = "") -> None:
2024
+ super().__init__()
2025
+ self.prefill = prefill
2026
+ self.output_lines: list[str] = []
2027
+ self.drafting = False
2028
+
2029
+ def compose(self) -> ComposeResult:
2030
+ theme = _dialog_theme(self)
2031
+ with Vertical(id="commit-body"):
2032
+ yield Label("[b]Commit message[/b]", id="commit-title")
2033
+ yield CommitTextArea(self.prefill, id="commit-message")
2034
+ with Horizontal(id="commit-buttons"):
2035
+ yield Button("Draft (F4)", id="commit-draft")
2036
+ yield Label("", id="commit-status")
2037
+ with ScrollableContainer(id="commit-output"):
2038
+ yield Static("", id="commit-output-content")
2039
+ yield Label(
2040
+ f"[{theme.dialog_hint}](F2 commit, F4 draft, Esc cancel)[/{theme.dialog_hint}]",
2041
+ id="commit-hint",
2042
+ )
2043
+
2044
+ def on_mount(self) -> None:
2045
+ """Park the cursor at the end of the prefill — after the branch
2046
+ prefix, or after the last line of a message reopened from the commit
2047
+ confirmation — so the user types after it rather than before it, and
2048
+ a message taller than the text area is scrolled to where the cursor
2049
+ is."""
2050
+ lines = self.prefill.split("\n")
2051
+ self.move_cursor_to(row=len(lines) - 1, column=len(lines[-1]))
2052
+
2053
+ def move_cursor_to(self, *, row: int, column: int) -> None:
2054
+ """Put the text cursor at `row`/`column`, if there is a text area to
2055
+ put it in — keys and worker results both arrive before compose() has
2056
+ run under the test stubs."""
2057
+ try:
2058
+ text_area = self.query_one("#commit-message", TextArea)
2059
+ except WIDGET_LOOKUP_ERRORS:
2060
+ return
2061
+ move_cursor = getattr(text_area, "move_cursor", None)
2062
+ if callable(move_cursor):
2063
+ try:
2064
+ move_cursor((row, column))
2065
+ except (TypeError, ValueError):
2066
+ pass
2067
+
2068
+ def get_text(self) -> str:
2069
+ try:
2070
+ return self.query_one("#commit-message", TextArea).text
2071
+ except WIDGET_LOOKUP_ERRORS:
2072
+ return ""
2073
+
2074
+ def set_text(self, message: str) -> None:
2075
+ """Replace the message with `message` and hand the user back the
2076
+ text area, so a drafted message can be edited straight away — the
2077
+ Draft button holds focus at the point this arrives."""
2078
+ try:
2079
+ text_area = self.query_one("#commit-message", TextArea)
2080
+ except WIDGET_LOOKUP_ERRORS:
2081
+ return
2082
+ load_text = getattr(text_area, "load_text", None)
2083
+ if callable(load_text):
2084
+ load_text(message)
2085
+ lines = message.split("\n")
2086
+ self.move_cursor_to(row=len(lines) - 1, column=len(lines[-1]))
2087
+ focus = getattr(text_area, "focus", None)
2088
+ if callable(focus):
2089
+ focus()
2090
+
2091
+ def set_drafting(self, drafting: bool) -> None:
2092
+ """Show or clear the "working" note, and lock the Draft button while
2093
+ the tool runs so a slow one can't be started twice.
2094
+
2095
+ Starting a run empties the log, which is also what clears a previous
2096
+ run's output off the dialog; a run that put lines there leaves them on
2097
+ screen when it finishes, because what the tool said about a message it
2098
+ couldn't write is exactly what the user needs to read.
2099
+ """
2100
+ if drafting:
2101
+ self.output_lines = []
2102
+ self.drafting = drafting
2103
+ theme = _dialog_theme(self)
2104
+ self._render_output()
2105
+ try:
2106
+ status = self.query_one("#commit-status", Label)
2107
+ button = self.query_one("#commit-draft", Button)
2108
+ except WIDGET_LOOKUP_ERRORS:
2109
+ return
2110
+ note = f"[{theme.dialog_hint}]Drafting commit message…[/{theme.dialog_hint}]"
2111
+ status.update(note if drafting else "")
2112
+ button.disabled = drafting
2113
+
2114
+ def append_output(self, line: str) -> None:
2115
+ """Add one line of the drafting tool's output to the log.
2116
+
2117
+ Called once per line the tool prints, from the UI thread — the worker
2118
+ hands the lines over through `call_from_thread`. Only the worker: a
2119
+ run on the previous screen has already shown the user every line, and
2120
+ printing them here as well would leave the message the user came for
2121
+ buried under a repeat of the run.
2122
+ """
2123
+ self.output_lines.append(line)
2124
+ # Drop the oldest lines rather than the newest: the tail is the part
2125
+ # that says what the tool is doing now.
2126
+ del self.output_lines[:-COMMIT_DRAFT_OUTPUT_LINES]
2127
+ self._render_output()
2128
+
2129
+ def _render_output(self) -> None:
2130
+ """Redraw the log pane, hidden until there is a run to show.
2131
+
2132
+ The pane is pinned to its last line so the newest output is the
2133
+ visible one without the user scrolling.
2134
+ """
2135
+ try:
2136
+ container = self.query_one("#commit-output", ScrollableContainer)
2137
+ content = self.query_one("#commit-output-content", Static)
2138
+ except WIDGET_LOOKUP_ERRORS:
2139
+ return
2140
+ content.update("\n".join(self.output_lines))
2141
+ container.display = self.drafting or bool(self.output_lines)
2142
+ container.scroll_end(animate=False)
2143
+
2144
+ def draft(self) -> None:
2145
+ """F4 / Draft: hand the request to the app, which owns the settings
2146
+ and the worker that keeps the tool off the UI thread.
2147
+
2148
+ Resolved by name rather than isinstance so this keeps working under
2149
+ the test stubs, where the app is a mock.
2150
+ """
2151
+ try:
2152
+ app = self.app
2153
+ except NO_SCREEN_ERRORS:
2154
+ return
2155
+ request = getattr(app, "request_commit_draft", None)
2156
+ if callable(request):
2157
+ request(self)
2158
+
2159
+ def on_button_pressed(self, event) -> None:
2160
+ event.stop()
2161
+ if getattr(event.button, "id", None) == "commit-draft":
2162
+ self.draft()
2163
+
2164
+ def action_commit_submit(self) -> None:
2165
+ self.submit()
2166
+
2167
+ def action_commit_draft(self) -> None:
2168
+ self.draft()
2169
+
2170
+ def action_commit_cancel(self) -> None:
2171
+ self.cancel()
2172
+
2173
+ def submit(self) -> None:
2174
+ """F2: hand the message back, refusing to dismiss on an empty one."""
2175
+ message = self.get_text().strip()
2176
+ if not message:
2177
+ self.app.notify("Commit message cannot be empty")
2178
+ return
2179
+ self.dismiss(message)
2180
+
2181
+ def cancel(self) -> None:
2182
+ self.dismiss(None)
2183
+
2184
+
2185
+ class SuggestionDialog(ModalScreen["str | None"]):
2186
+ """Modal list of the messages a drafting tool suggested.
2187
+
2188
+ Opened over the commit dialog when "Parse suggestions from numbered list"
2189
+ is on and the tool answered with more than one. Dismisses with the picked
2190
+ message, which the commit dialog loads into its text area — a pick is a
2191
+ starting point, not a commitment, and stays as editable as anything typed
2192
+ by hand.
2193
+
2194
+ Rows show each suggestion's first line: the list is for choosing between
2195
+ them, and a message with a body would push the rest off the screen.
2196
+ """
2197
+
2198
+ AUTO_FOCUS = "#suggestion-list"
2199
+
2200
+ DEFAULT_CSS = f"""
2201
+ SuggestionDialog {{
2202
+ align: center middle;
2203
+ }}
2204
+ SuggestionDialog #suggestion-body {{
2205
+ background: $panel;
2206
+ border: solid $accent;
2207
+ width: 100%;
2208
+ height: auto;
2209
+ max-height: 100%;
2210
+ padding: 1 2;
2211
+ }}
2212
+ SuggestionDialog Label {{
2213
+ width: 100%;
2214
+ height: auto;
2215
+ }}
2216
+ SuggestionDialog #suggestion-list {{
2217
+ width: 100%;
2218
+ height: auto;
2219
+ max-height: {SUGGESTION_DIALOG_MAX_ROWS};
2220
+ border: none;
2221
+ background: transparent;
2222
+ padding: 0;
2223
+ }}
2224
+ SuggestionDialog #suggestion-list:focus {{
2225
+ border: none;
2226
+ outline: none;
2227
+ }}
2228
+ """
2229
+
2230
+ def __init__(self, suggestions: list[str]) -> None:
2231
+ super().__init__()
2232
+ self.suggestions = list(suggestions)
2233
+
2234
+ def compose(self) -> ComposeResult:
2235
+ theme = _dialog_theme(self)
2236
+ with Vertical(id="suggestion-body"):
2237
+ yield Label("[b]Pick a commit message[/b]", id="suggestion-title")
2238
+ yield OptionList(
2239
+ *[Option(label) for label in self.option_labels()],
2240
+ id="suggestion-list",
2241
+ )
2242
+ yield Label(
2243
+ f"[{theme.dialog_hint}]"
2244
+ "(↑↓ move, 1-9 or Enter picks, Esc cancels)"
2245
+ f"[/{theme.dialog_hint}]",
2246
+ id="suggestion-hint",
2247
+ )
2248
+
2249
+ def option_labels(self) -> list[str]:
2250
+ """The rows as rendered: numbered as the tool numbered them, each cut
2251
+ to its first line. Escaped, because a message is the user's text and
2252
+ `[b]` in it is not markup."""
2253
+ return [
2254
+ f"{index}. {_escape_markup(_commit_message_subject(suggestion))}"
2255
+ for index, suggestion in enumerate(self.suggestions, start=1)
2256
+ ]
2257
+
2258
+ def pick(self, index: int) -> None:
2259
+ """Dismiss with suggestion `index` (0-based), ignoring one that isn't
2260
+ there — the digit keys can name a row past the end of a short list."""
2261
+ if 0 <= index < len(self.suggestions):
2262
+ self.dismiss(self.suggestions[index])
2263
+
2264
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
2265
+ event.stop()
2266
+ self.pick(event.option_index)
2267
+
2268
+ def on_key(self, event) -> None:
2269
+ """Esc cancels and a digit picks that row; arrows and Enter are left
2270
+ to the OptionList, which is what moves and selects."""
2271
+ if event.key == "escape":
2272
+ event.stop()
2273
+ event.prevent_default()
2274
+ self.dismiss(None)
2275
+ return
2276
+ character = getattr(event, "character", None)
2277
+ if isinstance(character, str) and character.isdigit() and event.key == character:
2278
+ event.stop()
2279
+ event.prevent_default()
2280
+ self.pick(int(character) - 1)
2281
+
2282
+
2283
+ DIFF_HUNK_PATTERN = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")
2284
+
2285
+ # Lines git repeats around every file in a patch. FileDiff drops them; the
2286
+ # commit view keeps them, since there they say which file follows.
2287
+ DIFF_FILE_HEADER_PREFIXES = ("diff ", "index ", "--- ", "+++ ")
2288
+
2289
+ # `commit <sha>` / `Author:` / `Date:` / … block that opens `git show` output.
2290
+ COMMIT_HEADER_PATTERN = re.compile(
2291
+ r"^(commit [0-9a-f]{7,}|Merge:|Author:|AuthorDate:|Commit:|CommitDate:|Date:)"
2292
+ )
2293
+
2294
+ # A `git show --stat` row: " path/to/file | 18 +++++++---------". Group 1 is
2295
+ # everything up to the graph, group 2 the run of +/- characters.
2296
+ DIFFSTAT_PATTERN = re.compile(r"^(\s+\S.*\|\s+\d+\s+)([+-]+)$")
2297
+
2298
+
2299
+ def _diff_line_styles(line: str, *, theme: UITheme) -> tuple[str, str | None]:
2300
+ """Return ``(gutter style, line style)`` for one unified-diff body line.
2301
+
2302
+ Shared by `FileDiff` and `render_commit_detail` so the two diff views
2303
+ color added/removed lines identically. The commit view has no
2304
+ line-number column and ignores the gutter style.
2305
+ """
2306
+ if line.startswith("+"):
2307
+ return theme.diff_added_line_number, theme.diff_added_line
2308
+ if line.startswith("-"):
2309
+ return theme.diff_removed_line_number, theme.diff_removed_line
2310
+ return theme.diff_context_line_number, None
2311
+
2312
+
2313
+ def render_commit_detail(detail_text: str, *, theme: UITheme) -> Text:
2314
+ """Render `git show` output for a commit into styled `Text`.
2315
+
2316
+ The header block and commit message keep git's own layout, the diffstat
2317
+ graph and any patch body get the same +/- colors `FileDiff` uses, and
2318
+ hunk markers are kept (unlike in `FileDiff`, where the line-number
2319
+ gutter already carries that information). No gutter is drawn here
2320
+ because the text spans several files rather than one.
2321
+ """
2322
+ text = Text()
2323
+ for index, line in enumerate(detail_text.splitlines()):
2324
+ if index:
2325
+ text.append("\n")
2326
+ if COMMIT_HEADER_PATTERN.match(line) or line.startswith(DIFF_FILE_HEADER_PREFIXES):
2327
+ text.append(line, style=theme.commit_header_line)
2328
+ continue
2329
+ stat = DIFFSTAT_PATTERN.match(line)
2330
+ if stat:
2331
+ text.append(stat.group(1))
2332
+ for char in stat.group(2):
2333
+ added = char == "+"
2334
+ text.append(
2335
+ char,
2336
+ style=theme.diff_added_line if added else theme.diff_removed_line,
2337
+ )
2338
+ continue
2339
+ if DIFF_HUNK_PATTERN.match(line):
2340
+ text.append(line, style=theme.diff_hunk_header)
2341
+ continue
2342
+ _, line_style = _diff_line_styles(line, theme=theme)
2343
+ text.append(line, style=line_style)
2344
+ return text
2345
+
2346
+
2347
+ class CommitDetail(Static):
2348
+ """Shows the full diff for the selected commit."""
2349
+
2350
+ DEFAULT_CSS = """
2351
+ CommitDetail {
2352
+ border: solid $accent;
2353
+ padding: 1 2;
2354
+ height: 1fr;
2355
+ overflow-y: auto;
2356
+ }
2357
+ """
2358
+
2359
+ def show(self, detail_text: str) -> None:
2360
+ theme = self.app._active_ui_theme() # pyright: ignore[reportAttributeAccessIssue]
2361
+ self.update(render_commit_detail(detail_text, theme=theme))
2362
+
2363
+
2364
+ class FileDiff(ScrollableContainer):
2365
+ """Shows the diff for a selected status entry."""
2366
+
2367
+ can_focus = True
2368
+
2369
+ DEFAULT_CSS = """
2370
+ FileDiff {
2371
+ border: solid $accent;
2372
+ height: 1fr;
2373
+ display: none;
2374
+ }
2375
+ FileDiff:focus {
2376
+ border: solid $success;
2377
+ }
2378
+ FileDiff Static {
2379
+ width: 100%;
2380
+ height: auto;
2381
+ padding: 1 2;
2382
+ }
2383
+ """
2384
+
2385
+ def compose(self) -> ComposeResult:
2386
+ yield Static("", id="diff-content")
2387
+
2388
+ def on_key(self, event) -> None:
2389
+ if event.key == "up":
2390
+ self.scroll_up(animate=False)
2391
+ event.stop()
2392
+ elif event.key == "down":
2393
+ self.scroll_down(animate=False)
2394
+ event.stop()
2395
+
2396
+ def show(self, diff_text: str, filename: str = "") -> None:
2397
+ self.display = True
2398
+ self.border_title = filename
2399
+ self.scroll_home(animate=False)
2400
+ theme = self.app._active_ui_theme() # pyright: ignore[reportAttributeAccessIssue]
2401
+ text = Text()
2402
+ old_line = 0
2403
+ new_line = 0
2404
+ first = True
2405
+
2406
+ def nl():
2407
+ nonlocal first
2408
+ if not first:
2409
+ text.append("\n")
2410
+ first = False
2411
+
2412
+ for line in diff_text.splitlines():
2413
+ hunk = DIFF_HUNK_PATTERN.match(line)
2414
+ if hunk:
2415
+ old_line = int(hunk.group(1))
2416
+ new_line = int(hunk.group(2))
2417
+ continue
2418
+
2419
+ if line.startswith(DIFF_FILE_HEADER_PREFIXES):
2420
+ continue
2421
+
2422
+ nl()
2423
+ gutter_style, line_style = _diff_line_styles(line, theme=theme)
2424
+ if line.startswith("+"):
2425
+ number = new_line
2426
+ new_line += 1
2427
+ elif line.startswith("-"):
2428
+ number = old_line
2429
+ old_line += 1
2430
+ else:
2431
+ number = new_line
2432
+ old_line += 1
2433
+ new_line += 1
2434
+ text.append(f" {number:>4} ", style=gutter_style)
2435
+ text.append(line, style=line_style)
2436
+
2437
+ self.query_one("#diff-content", Static).update(text)
2438
+ self.focus()
2439
+
2440
+ def show_content(self, content: str, filename: str = "") -> None:
2441
+ """Render plain file text with a line-number gutter, no diff parsing.
2442
+
2443
+ Used for untracked files where there is no diff to display.
2444
+ """
2445
+ self.display = True
2446
+ self.border_title = filename
2447
+ self.scroll_home(animate=False)
2448
+ theme = self.app._active_ui_theme() # pyright: ignore[reportAttributeAccessIssue]
2449
+ text = Text()
2450
+ for n, line in enumerate(content.splitlines(), 1):
2451
+ if n > 1:
2452
+ text.append("\n")
2453
+ text.append(f" {n:>4} ", style=theme.diff_context_line_number)
2454
+ text.append(line)
2455
+ self.query_one("#diff-content", Static).update(text)
2456
+ self.focus()
2457
+
2458
+
2459
+ class CommitList(ListView):
2460
+ """Scrollable list of git commits."""
2461
+
2462
+ DEFAULT_CSS = """
2463
+ CommitList {
2464
+ width: 1fr;
2465
+ border: solid $accent;
2466
+ height: 1fr;
2467
+ }
2468
+ ListItem {
2469
+ padding: 0 1;
2470
+ }
2471
+ ListItem:hover {
2472
+ background: $boost;
2473
+ }
2474
+ ListItem.-highlight {
2475
+ background: $accent 30%;
2476
+ }
2477
+ """
2478
+
2479
+
2480
+ class StatusListItem(ListItem):
2481
+ """ListItem that only forwards double-clicks. Single clicks highlight only.
2482
+
2483
+ Textual's ListItem._on_click posts _ChildClicked on every click regardless
2484
+ of chain length, so single-clicks end up firing ListView.Selected. We want
2485
+ file-manager semantics: single click = highlight, double click = open.
2486
+
2487
+ Why: Textual dispatches `_on_click` to every class in the MRO
2488
+ (textual/message_pump.py:758 — it walks `self.__class__.__mro__` and
2489
+ yields each class's own `_on_click`). So plain `event.stop()` here is NOT
2490
+ enough — `ListItem._on_click` would still run and post `_ChildClicked`.
2491
+ `event.prevent_default()` sets `_no_default_action`, which breaks the MRO
2492
+ loop (message_pump.py:759) and skips the base-class handler.
2493
+
2494
+ Keyboard Enter is unaffected because it bypasses `_on_click` entirely
2495
+ (ListView.action_select_cursor posts Selected directly).
2496
+ """
2497
+
2498
+ def _on_click(self, event) -> None: # pyright: ignore[reportIncompatibleMethodOverride]
2499
+ # CTRL+click toggles multi-selection. SHIFT+click is reserved by most
2500
+ # terminals for native text-selection, so we use CTRL which is
2501
+ # forwarded reliably. Guard with `is True` so MagicMock attributes in
2502
+ # tests (truthy by default) don't accidentally take the ctrl branch.
2503
+ if getattr(event, "ctrl", False) is True:
2504
+ event.prevent_default()
2505
+ event.stop()
2506
+ list_view = self.parent
2507
+ if list_view is None:
2508
+ return
2509
+ try:
2510
+ index = list_view.children.index(self) # pyright: ignore[reportAttributeAccessIssue]
2511
+ except (AttributeError, ValueError):
2512
+ return
2513
+ app = self.app
2514
+ toggle = getattr(app, "_toggle_status_selection", None)
2515
+ if callable(toggle):
2516
+ toggle(index)
2517
+ return
2518
+ if getattr(event, "chain", 1) >= 2:
2519
+ return
2520
+ event.prevent_default()
2521
+ event.stop()
2522
+ # Replicate the highlight half of ListView._on_list_item__child_clicked
2523
+ # (focus + move cursor) without the Selected message that opens the
2524
+ # file. Arrow keys then continue from this row.
2525
+ list_view = self.parent
2526
+ if list_view is None:
2527
+ return
2528
+ list_view.focus() # pyright: ignore[reportAttributeAccessIssue]
2529
+ try:
2530
+ list_view.index = list_view.children.index(self) # pyright: ignore[reportAttributeAccessIssue]
2531
+ except (AttributeError, ValueError):
2532
+ pass
2533
+
2534
+
2535
+ class StatusList(ListView):
2536
+ """Scrollable list of git status entries."""
2537
+
2538
+ DEFAULT_CSS = """
2539
+ StatusList {
2540
+ border: solid $accent;
2541
+ height: 1fr;
2542
+ }
2543
+ StatusList ListItem {
2544
+ padding: 0 1;
2545
+ }
2546
+ StatusList ListItem:hover {
2547
+ background: $boost;
2548
+ }
2549
+ StatusList ListItem.-highlight {
2550
+ background: $cursor-row-background;
2551
+ color: $cursor-row-foreground;
2552
+ }
2553
+ StatusList ListItem.selected {
2554
+ background: $selected-file-background;
2555
+ color: $selected-file-foreground;
2556
+ text-style: bold;
2557
+ }
2558
+ StatusList ListItem.selected.-highlight {
2559
+ background: $selected-cursor-row-background;
2560
+ color: $selected-cursor-row-foreground;
2561
+ text-style: bold;
2562
+ }
2563
+ """
2564
+
2565
+ def on_resize(self, event) -> None:
2566
+ """Re-flow rows so the right-aligned columns stay flush to the right
2567
+ of the list when the terminal is resized."""
2568
+ refresh = getattr(self.app, "_refresh_status_layout", None)
2569
+ if callable(refresh):
2570
+ refresh()
2571
+
2572
+ def on_focus(self, event) -> None:
2573
+ """Re-apply the highlight class to the current row when the list is
2574
+ focused again.
2575
+
2576
+ Why: the row keeps its index while focus is elsewhere (the inline
2577
+ diff, a dialog), but the ``-highlight`` class can have been dropped by
2578
+ a rebuild in the meantime, and Textual only re-runs ``watch_index``
2579
+ when the value changes.
2580
+ """
2581
+ index = self.index
2582
+ if index is not None:
2583
+ self.watch_index(index, index)
2584
+
2585
+ def on_key(self, event) -> None:
2586
+ if event.key in ("shift+up", "shift+down"):
2587
+ extend = getattr(self.app, "_extend_status_selection", None)
2588
+ if callable(extend):
2589
+ extend(-1 if event.key == "shift+up" else 1)
2590
+ event.stop()
2591
+ event.prevent_default()
2592
+ elif event.key == "space":
2593
+ toggle = getattr(self.app, "_toggle_current_status_selection", None)
2594
+ if callable(toggle):
2595
+ toggle()
2596
+ event.stop()
2597
+ event.prevent_default()
2598
+
2599
+
2600
+ class GitNightCommanderApp(App):
2601
+ """A simple TUI git log browser."""
2602
+
2603
+ TITLE = "GitNightCommander"
2604
+ SUB_TITLE = "git log browser"
2605
+
2606
+ # Disable Textual's command palette entirely. This also hides the
2607
+ # built-in Screenshot system command, which is only reachable through
2608
+ # the palette.
2609
+ ENABLE_COMMAND_PALETTE = False
2610
+
2611
+ # Textual's default AUTO_FOCUS is "*", which focuses whichever widget
2612
+ # happens to answer `focusable` first — including hidden overlays such as
2613
+ # the settings dialog, whose readiness is timing-dependent. Name the file
2614
+ # list explicitly so startup focus is deterministic.
2615
+ AUTO_FOCUS = "#status_list"
2616
+
2617
+ # Ordered list of menu IDs — drives F9 open and left/right navigation.
2618
+ MENU_ORDER: ClassVar[list[str]] = [menu_id for _, menu_id in MENU_BAR]
2619
+
2620
+ # Aliases are bound with show=False: the footer keys off the binding, not
2621
+ # the action, so a shown alias would list its action's description twice.
2622
+ BINDINGS: ClassVar[list[BindingType]] = [
2623
+ *(
2624
+ Binding(
2625
+ key,
2626
+ action,
2627
+ description,
2628
+ show=show,
2629
+ key_display=_shortcut_key_display(key),
2630
+ priority=(action == "quit"),
2631
+ )
2632
+ for key, action, description, show in SHORTCUTS
2633
+ ),
2634
+ *(
2635
+ Binding(key, action, SHORTCUT_DESCRIPTIONS_BY_ACTION.get(action, action), show=False)
2636
+ for key, action in SHORTCUT_ALIASES
2637
+ ),
2638
+ ]
2639
+
2640
+ CSS = """
2641
+ Screen {
2642
+ layout: horizontal;
2643
+ layers: default overlay;
2644
+ background: $background;
2645
+ color: $foreground;
2646
+ }
2647
+ Header {
2648
+ background: $panel;
2649
+ color: $foreground;
2650
+ }
2651
+ ContentSwitcher {
2652
+ width: 100%;
2653
+ height: 1fr;
2654
+ }
2655
+ #history-view {
2656
+ width: 100%;
2657
+ height: 1fr;
2658
+ }
2659
+ #history-left {
2660
+ width: 50%;
2661
+ }
2662
+ #history-right {
2663
+ width: 50%;
2664
+ }
2665
+ #status-view {
2666
+ width: 100%;
2667
+ height: 1fr;
2668
+ layout: vertical;
2669
+ }
2670
+ #status-body {
2671
+ width: 100%;
2672
+ height: 1fr;
2673
+ }
2674
+ #status_list {
2675
+ width: 40%;
2676
+ min-width: 20;
2677
+ }
2678
+ #file-diff {
2679
+ width: 60%;
2680
+ }
2681
+ #status-view.long-mode #status_list {
2682
+ width: 100%;
2683
+ }
2684
+ #status-view.long-mode #file-diff {
2685
+ display: none;
2686
+ }
2687
+ #column-header {
2688
+ height: 1;
2689
+ width: 100%;
2690
+ padding: 0 2;
2691
+ color: $text-muted;
2692
+ background: $panel;
2693
+ }
2694
+ #submodule-warnings {
2695
+ height: auto;
2696
+ width: 100%;
2697
+ padding: 0 2;
2698
+ background: $panel;
2699
+ }
2700
+ #submodule-warnings.empty {
2701
+ display: none;
2702
+ }
2703
+ #diff-view {
2704
+ width: 100%;
2705
+ height: 1fr;
2706
+ }
2707
+ #diff-full {
2708
+ width: 100%;
2709
+ height: 1fr;
2710
+ }
2711
+ #meta {
2712
+ height: 3;
2713
+ padding: 0 2;
2714
+ border: solid $accent;
2715
+ color: $text-muted;
2716
+ }
2717
+ #status-header {
2718
+ height: 1;
2719
+ width: 100%;
2720
+ padding: 0 1;
2721
+ color: $text-muted;
2722
+ background: $panel;
2723
+ }
2724
+ """
2725
+
2726
+ def __init__(self, repo_path: str = ".") -> None:
2727
+ super().__init__()
2728
+ self._register_app_themes()
2729
+ self.theme = MIDNIGHT_COMMANDER_THEME_NAME
2730
+ self.repo_path = repo_path
2731
+ self.git = GIT(repo_path)
2732
+ self._screen = Terminal()
2733
+ self.commits: list[Commit] = []
2734
+ self.status_filelist: GitFilelist = GitFilelist()
2735
+ self.file_list_mode: str = MODE_SHORT
2736
+ self.file_list_filter: str = FILTER_NONE
2737
+ self._last_status_entry: GitEntry | None = None
2738
+ self._pending_commit_externals: list[str] = []
2739
+ self._pending_commit_staged: list[GitEntry] = []
2740
+ self._pending_commit_targets: list[GitEntry] = []
2741
+ # Filled in by the status worker; _update_submodule_warnings renders
2742
+ # from this cache so a resize can re-flow the list without shelling
2743
+ # out to git again.
2744
+ self._submodule_mismatches: list[tuple[str, str | None, str | None]] = []
2745
+ # Newest issued serial per worker group; see _new_request.
2746
+ self._request_serials: dict[str, int] = {}
2747
+ # True while a pull or push worker is in flight. `exclusive=True`
2748
+ # wouldn't help here: it cancels a superseded worker's asyncio task,
2749
+ # but the thread keeps running, so a second P would reach the network
2750
+ # rather than replace the first push.
2751
+ self._remote_running: bool = False
2752
+
2753
+ def _register_app_themes(self) -> None:
2754
+ for ui_theme in THEMES:
2755
+ self.register_theme(ui_theme.get_theme())
2756
+
2757
+ def _active_ui_theme(self) -> UITheme:
2758
+ """Return the UITheme instance backing the currently active theme.
2759
+
2760
+ Falls back to MIDNIGHT_COMMANDER_THEME when the active name does not
2761
+ match any registered theme (e.g. when self.theme is unset in tests).
2762
+ """
2763
+ name = getattr(self, "theme", None)
2764
+ return _UI_THEMES_BY_NAME.get(name or "", MIDNIGHT_COMMANDER_THEME)
2765
+
2766
+ @property
2767
+ def status_entries(self) -> GitFilelist:
2768
+ return self.status_filelist
2769
+
2770
+ @status_entries.setter
2771
+ def status_entries(self, entries: GitFilelist | list[GitEntry]) -> None:
2772
+ if isinstance(entries, GitFilelist):
2773
+ self.status_filelist = entries
2774
+ return
2775
+ self.status_filelist = GitFilelist(entries)
2776
+
2777
+ def compose(self) -> ComposeResult:
2778
+ yield Header()
2779
+ yield MenuBar()
2780
+ with ContentSwitcher(initial="status-view"):
2781
+ with Vertical(id="status-view"):
2782
+ yield Label("", id="submodule-warnings", classes="empty")
2783
+ yield Label("", id="column-header")
2784
+ with Horizontal(id="status-body"):
2785
+ yield StatusList(id="status_list")
2786
+ yield FileDiff(id="file-diff")
2787
+ yield Label("", id="status-header")
2788
+ with Horizontal(id="history-view"):
2789
+ with Vertical(id="history-left"):
2790
+ yield CommitList(id="commit_list")
2791
+ with Vertical(id="history-right"):
2792
+ yield Label("", id="meta")
2793
+ yield CommitDetail(id="detail")
2794
+ with Vertical(id="diff-view"):
2795
+ yield FileDiff(id="diff-full")
2796
+ yield DropdownMenu(self._file_menu_items(), id="dropdown_file")
2797
+ yield DropdownMenu(REPO_MENU, id="dropdown_repo")
2798
+ yield DropdownMenu(VIEW_MENU, id="dropdown_view")
2799
+ yield DropdownMenu(OPTIONS_MENU, id="dropdown_options")
2800
+ yield DropdownMenu(HELP_MENU, id="dropdown_help")
2801
+ for submenu in SUBMENUS:
2802
+ yield DropdownMenu(submenu.items, id=submenu.dropdown_id)
2803
+ yield Footer()
2804
+
2805
+ def on_mount(self) -> None:
2806
+ self._load_saved_theme()
2807
+ self._load_status()
2808
+ self._load_commits()
2809
+ self._warn_if_started_from_midnight_commander()
2810
+
2811
+ def _warn_if_started_from_midnight_commander(self) -> None:
2812
+ if not started_from_midnight_commander():
2813
+ return
2814
+
2815
+ def resolved(continue_: bool | None) -> None:
2816
+ if continue_:
2817
+ self._focus_active_view()
2818
+ else:
2819
+ self.action_quit()
2820
+
2821
+ self.push_screen(
2822
+ MidnightCommanderDialog(message=MIDNIGHT_COMMANDER_WARNING),
2823
+ callback=resolved,
2824
+ )
2825
+
2826
+ def _settings_path(self) -> Path:
2827
+ config_home = os.environ.get("XDG_CONFIG_HOME")
2828
+ config_root = Path(config_home).expanduser() if config_home else Path.home() / ".config"
2829
+ return config_root / "gitnc" / SETTINGS_FILENAME
2830
+
2831
+ def _load_settings(self) -> dict[str, object]:
2832
+ try:
2833
+ settings = json.loads(self._settings_path().read_text(encoding="utf-8"))
2834
+ except FileNotFoundError:
2835
+ return {}
2836
+ except (OSError, json.JSONDecodeError):
2837
+ return {}
2838
+ return settings if isinstance(settings, dict) else {}
2839
+
2840
+ def _save_settings(self, settings: dict[str, object]) -> bool:
2841
+ settings_path = self._settings_path()
2842
+ try:
2843
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
2844
+ settings_path.write_text(
2845
+ json.dumps(settings, indent=2, sort_keys=True) + "\n",
2846
+ encoding="utf-8",
2847
+ )
2848
+ except OSError:
2849
+ return False
2850
+ return True
2851
+
2852
+ def _load_saved_theme(self) -> None:
2853
+ theme_name = self._load_settings().get("theme")
2854
+ if not isinstance(theme_name, str):
2855
+ return
2856
+ try:
2857
+ self.theme = theme_name
2858
+ except InvalidThemeError:
2859
+ return
2860
+ self._restyle_menu_mnemonics()
2861
+
2862
+ @staticmethod
2863
+ def _menu_label(text: str, *, marker: str = MENU_MARKER_BLANK) -> str:
2864
+ """Prefix *text* with a one-cell marker gutter and its separating gap."""
2865
+ return f"{marker}{MENU_MARKER_GAP}{text}"
2866
+
2867
+ def _file_menu_items(self) -> list[tuple[str, str]]:
2868
+ def radio(mode: str) -> str:
2869
+ return MENU_RADIO_ON if self.file_list_mode == mode else MENU_RADIO_OFF
2870
+
2871
+ def filter_radio(file_filter: str) -> str:
2872
+ return MENU_RADIO_ON if self.file_list_filter == file_filter else MENU_RADIO_OFF
2873
+
2874
+ return [
2875
+ (
2876
+ self._menu_label("&Short file list", marker=radio(MODE_SHORT)),
2877
+ "short_file_list",
2878
+ ),
2879
+ (
2880
+ self._menu_label("&Long file list", marker=radio(MODE_LONG)),
2881
+ "long_file_list",
2882
+ ),
2883
+ (
2884
+ self._menu_label("&All files", marker=filter_radio(FILTER_NONE)),
2885
+ "file_filter_none",
2886
+ ),
2887
+ (
2888
+ self._menu_label(
2889
+ "Collapse &untracked folders", marker=filter_radio(FILTER_UNTRACKED_DIRS)
2890
+ ),
2891
+ "file_filter_untracked_dirs",
2892
+ ),
2893
+ (self._menu_label("&Refresh"), "refresh"),
2894
+ (self._menu_label("&Delete"), "delete_file"),
2895
+ (self._menu_label("&Commit"), "commit_files"),
2896
+ (self._menu_label("&Quit"), "quit"),
2897
+ ]
2898
+
2899
+ def _name_width(self) -> int:
2900
+ fallback = LONG_NAME_WIDTH if self.file_list_mode == MODE_LONG else SHORT_NAME_WIDTH
2901
+ try:
2902
+ status_list = self.query_one("#status_list", StatusList)
2903
+ except WIDGET_LOOKUP_ERRORS:
2904
+ return fallback
2905
+
2906
+ content_region = getattr(status_list, "content_region", None)
2907
+ content_width = getattr(content_region, "width", None)
2908
+ if not isinstance(content_width, int):
2909
+ return fallback
2910
+
2911
+ row_text_width = content_width - STATUS_LIST_ITEM_HORIZONTAL_PADDING
2912
+ if row_text_width <= 0:
2913
+ return fallback
2914
+
2915
+ fixed_width = STATUS_WIDTH + SIZE_WIDTH + MTIME_WIDTH + (3 * COLUMN_GAP)
2916
+ return max(1, row_text_width - fixed_width)
2917
+
2918
+ def _column_header_text(self, name_width: int | None = None) -> str:
2919
+ name_width = self._name_width() if name_width is None else name_width
2920
+ return (
2921
+ f"{'Status':<{STATUS_WIDTH}} "
2922
+ f"{'Name':<{name_width}} "
2923
+ f"{'Size':>{SIZE_WIDTH}} "
2924
+ f"{'Modify time':>{MTIME_WIDTH}}"
2925
+ )
2926
+
2927
+ # ------------------------------------------------------------------
2928
+ # Git reads (thread workers)
2929
+ #
2930
+ # Every git read that grows with the repo runs on a `@work(thread=True)`
2931
+ # worker: doing it inline blocks Textual's event loop, which freezes the
2932
+ # whole UI until the subprocess returns. Each one is a trio — a launcher
2933
+ # on the UI thread, a worker that only fetches, and an `_apply_*` that
2934
+ # only renders, reached via `call_from_thread`.
2935
+ # ------------------------------------------------------------------
2936
+
2937
+ def _new_request(self, group: str) -> int:
2938
+ """Issue a serial identifying the newest request in *group*.
2939
+
2940
+ Why: `exclusive=True` cancels the asyncio task of a superseded worker,
2941
+ but a *thread* worker keeps running to completion, so two results for
2942
+ the same group can arrive in either order. Each `_apply_*` drops a
2943
+ result whose serial is no longer current, so the newest request always
2944
+ wins regardless of which git call finishes first.
2945
+ """
2946
+ serial = self._request_serials.get(group, 0) + 1
2947
+ self._request_serials[group] = serial
2948
+ return serial
2949
+
2950
+ def _is_current_request(self, group: str, serial: int) -> bool:
2951
+ return self._request_serials.get(group) == serial
2952
+
2953
+ def _load_commits(self) -> None:
2954
+ """Reload the history list. `git log` runs on a worker thread."""
2955
+ self._load_commits_worker(serial=self._new_request("history"))
2956
+
2957
+ @work(thread=True, exclusive=True, group="git-history")
2958
+ def _load_commits_worker(self, *, serial: int) -> None:
2959
+ commits = self.git.load_commits()
2960
+ self.call_from_thread(self._apply_commits, commits=commits, serial=serial)
2961
+
2962
+ def _apply_commits(self, *, commits: list[Commit], serial: int) -> None:
2963
+ if not self._is_current_request("history", serial):
2964
+ return
2965
+ self.commits = commits
2966
+ list_view = self.query_one("#commit_list", CommitList)
2967
+ list_view.clear()
2968
+ for commit in self.commits:
2969
+ short_hash = commit.hash[:7]
2970
+ list_view.append(
2971
+ ListItem(Label(f"[bold]{short_hash}[/bold] {commit.date} {commit.subject}"))
2972
+ )
2973
+
2974
+ def _load_status(
2975
+ self,
2976
+ *,
2977
+ restore: GitEntry | None = None,
2978
+ refocus: bool = False,
2979
+ clear_diff: bool = True,
2980
+ ) -> None:
2981
+ """Reload the file list. `git status` runs on a worker thread.
2982
+
2983
+ Why: on a repo of any size `git status` plus the per-submodule branch
2984
+ lookups take long enough to stall Textual's event loop, which freezes
2985
+ keyboard and mouse input until they return.
2986
+ How to apply: pass *restore* instead of calling
2987
+ `_schedule_restore_selection` after the call — the reload is now
2988
+ asynchronous, so a restore issued at the call site would run against
2989
+ the pre-reload list. *refocus* is forwarded to that restore.
2990
+ """
2991
+ self._load_status_worker(
2992
+ serial=self._new_request("status"),
2993
+ previous_selections=set(self.status_filelist.selected_filenames),
2994
+ collapse_untracked_dirs=self.file_list_filter == FILTER_UNTRACKED_DIRS,
2995
+ restore=restore,
2996
+ refocus=refocus,
2997
+ clear_diff=clear_diff,
2998
+ )
2999
+
3000
+ @work(thread=True, exclusive=True, group="git-status")
3001
+ def _load_status_worker(
3002
+ self,
3003
+ *,
3004
+ serial: int,
3005
+ previous_selections: set[str],
3006
+ collapse_untracked_dirs: bool,
3007
+ restore: GitEntry | None,
3008
+ refocus: bool,
3009
+ clear_diff: bool,
3010
+ ) -> None:
3011
+ """Off-thread half of `_load_status`: fetch only, touch no widgets."""
3012
+ filelist = self.git.load_status(collapse_untracked_dirs=collapse_untracked_dirs)
3013
+ mismatches = self.git.submodule_branch_mismatches()
3014
+ self.call_from_thread(
3015
+ self._apply_status,
3016
+ serial=serial,
3017
+ filelist=filelist,
3018
+ previous_selections=previous_selections,
3019
+ mismatches=mismatches,
3020
+ restore=restore,
3021
+ refocus=refocus,
3022
+ clear_diff=clear_diff,
3023
+ )
3024
+
3025
+ def _apply_status(
3026
+ self,
3027
+ *,
3028
+ serial: int,
3029
+ filelist: GitFilelist,
3030
+ previous_selections: set[str],
3031
+ mismatches: list[tuple[str, str | None, str | None]],
3032
+ restore: GitEntry | None = None,
3033
+ refocus: bool = False,
3034
+ clear_diff: bool = True,
3035
+ ) -> None:
3036
+ """UI-thread half of `_load_status`: render what the worker fetched."""
3037
+ if not self._is_current_request("status", serial):
3038
+ return
3039
+ self.status_filelist = filelist
3040
+ self.status_filelist.restore_selections(previous_selections)
3041
+ self._submodule_mismatches = mismatches
3042
+ if clear_diff:
3043
+ file_diff = self.query_one("#file-diff", FileDiff)
3044
+ file_diff.display = False
3045
+ file_diff.border_title = ""
3046
+ self._render_status_rows()
3047
+ if restore is not None:
3048
+ self._schedule_restore_selection(restore, refocus_active_view=refocus)
3049
+
3050
+ def _render_status_rows(self) -> None:
3051
+ list_view = self.query_one("#status_list", StatusList)
3052
+ list_view.clear()
3053
+ name_width = self._name_width()
3054
+ self.query_one("#column-header", Label).update(self._column_header_text(name_width))
3055
+ ui_theme = self._active_ui_theme()
3056
+ row_texts = self.status_filelist.row_texts(
3057
+ self.repo_path,
3058
+ status_width=STATUS_WIDTH,
3059
+ name_width=name_width,
3060
+ size_width=SIZE_WIDTH,
3061
+ mtime_width=MTIME_WIDTH,
3062
+ status_colors=ui_theme.status_color_map(),
3063
+ submodule_color=ui_theme.status_submodule,
3064
+ )
3065
+ for index, row_text in enumerate(row_texts):
3066
+ classes = "selected" if self.status_filelist.is_selected(index) else ""
3067
+ list_view.append(StatusListItem(Label(row_text), classes=classes))
3068
+ self._update_submodule_warnings()
3069
+ self._update_status_header()
3070
+
3071
+ def _update_submodule_warnings(self) -> None:
3072
+ """Render the cached submodule/parent branch mismatches.
3073
+
3074
+ Reads `_submodule_mismatches` rather than querying git: this runs on
3075
+ every re-render, including resize-driven ones, and each mismatch check
3076
+ is a `git rev-parse` per submodule.
3077
+ """
3078
+ try:
3079
+ label = self.query_one("#submodule-warnings", Label)
3080
+ except WIDGET_LOOKUP_ERRORS:
3081
+ return
3082
+ mismatches = self._submodule_mismatches
3083
+ if not mismatches:
3084
+ label.update("")
3085
+ label.add_class("empty")
3086
+ return
3087
+ style = self._active_ui_theme().warning_text
3088
+ lines: list[str] = []
3089
+ for sub_path, sub_branch, parent_branch in mismatches:
3090
+ text = f"Warning: {_submodule_mismatch_text(sub_path, sub_branch, parent_branch)}"
3091
+ lines.append(f"[{style}]{text}[/{style}]" if style else text)
3092
+ label.update("\n".join(lines))
3093
+ label.remove_class("empty")
3094
+
3095
+ def _update_status_header(self) -> None:
3096
+ try:
3097
+ header = self.query_one("#status-header", Label)
3098
+ except WIDGET_LOOKUP_ERRORS:
3099
+ return
3100
+ message = self.status_filelist.status_message()
3101
+ # Before the entry's description, whose length changes with every
3102
+ # row, so the filter state stays in one place on the line.
3103
+ message = f"{message} | {FILE_LIST_FILTER_LABELS[self.file_list_filter]}"
3104
+ entry = self.status_filelist.highlighted_entry
3105
+ if entry is not None:
3106
+ message = f"{message} | {entry.code.long_description}"
3107
+ header.update(message)
3108
+
3109
+ def _toggle_status_selection(self, index: int) -> None:
3110
+ """Toggle selection at *index* in the file list.
3111
+
3112
+ Why: CTRL+click lives on the item but the selection state lives on
3113
+ `GitFilelist`, so the item delegates back to the app.
3114
+ How to apply: called from StatusListItem._on_click (CTRL branch).
3115
+ """
3116
+ self.status_filelist.toggle_selection(index)
3117
+ self._apply_selection_classes()
3118
+
3119
+ def _toggle_current_status_selection(self) -> None:
3120
+ """Space key: toggle selection on the currently-highlighted row.
3121
+
3122
+ Unlike SHIFT+arrow, this does NOT move the cursor — the user stays
3123
+ on the row so they can immediately see whether they selected or
3124
+ deselected it.
3125
+ """
3126
+ try:
3127
+ list_view = self.query_one("#status_list", StatusList)
3128
+ except WIDGET_LOOKUP_ERRORS:
3129
+ return
3130
+ idx = list_view.index
3131
+ if not isinstance(idx, int):
3132
+ return
3133
+ self.status_filelist.toggle_selection(idx)
3134
+ self._apply_selection_classes()
3135
+
3136
+ def _extend_status_selection(self, delta: int) -> None:
3137
+ """SHIFT+arrow: toggle current row, then move cursor by *delta*."""
3138
+ try:
3139
+ list_view = self.query_one("#status_list", StatusList)
3140
+ except WIDGET_LOOKUP_ERRORS:
3141
+ return
3142
+ idx = list_view.index
3143
+ if not isinstance(idx, int):
3144
+ return
3145
+ self.status_filelist.toggle_selection(idx)
3146
+ new_idx = idx + delta
3147
+ if 0 <= new_idx < len(self.status_filelist):
3148
+ list_view.index = new_idx
3149
+ self._apply_selection_classes()
3150
+
3151
+ def _apply_selection_classes(self) -> None:
3152
+ try:
3153
+ list_view = self.query_one("#status_list", StatusList)
3154
+ except WIDGET_LOOKUP_ERRORS:
3155
+ return
3156
+ children = getattr(list_view, "children", None) or []
3157
+ for index, child in enumerate(children):
3158
+ is_selected = self.status_filelist.is_selected(index)
3159
+ add = getattr(child, "add_class", None)
3160
+ remove = getattr(child, "remove_class", None)
3161
+ if is_selected and callable(add):
3162
+ add("selected")
3163
+ elif callable(remove):
3164
+ remove("selected")
3165
+
3166
+ def _refresh_status_layout(self) -> None:
3167
+ """Re-render the status list using the cached filelist.
3168
+
3169
+ Why: resize events must re-flow the Size / Modify time columns so
3170
+ they stay flush to the right edge of the list, but must not re-run
3171
+ git status on every resize.
3172
+ How to apply: called from StatusList.on_resize; safe to no-op if the
3173
+ DOM isn't ready (first mount, teardown).
3174
+ """
3175
+ try:
3176
+ self.query_one("#status_list", StatusList)
3177
+ except WIDGET_LOOKUP_ERRORS:
3178
+ return
3179
+ current_entry = self._current_status_entry()
3180
+ self._render_status_rows()
3181
+ if current_entry is not None:
3182
+ self._schedule_restore_selection(current_entry)
3183
+
3184
+ def on_list_view_highlighted(self, event) -> None:
3185
+ if event.list_view.id != "status_list":
3186
+ return
3187
+ self.status_filelist.set_highlighted_index(event.list_view.index)
3188
+ self._update_status_header()
3189
+
3190
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
3191
+ index = event.list_view.index
3192
+ if event.list_view.id == "status_list":
3193
+ entry = self.status_filelist.set_highlighted_index(index)
3194
+ if entry is None:
3195
+ return
3196
+ self._last_status_entry = entry
3197
+ if self.file_list_mode == MODE_LONG:
3198
+ self.query_one(ContentSwitcher).current = "diff-view"
3199
+ self._render_entry_into("diff-full", entry)
3200
+ else:
3201
+ self._render_entry_into("file-diff", entry)
3202
+ return
3203
+ if event.list_view.id != "commit_list":
3204
+ return
3205
+ if index is None or index >= len(self.commits):
3206
+ return
3207
+ commit = self.commits[index]
3208
+ meta = self.query_one("#meta", Label)
3209
+ meta.update(f"[bold]{commit.hash[:7]}[/bold] {commit.author} {commit.date}")
3210
+ self._load_commit_detail(commit.hash)
3211
+
3212
+ def _load_commit_detail(self, commit_hash: str) -> None:
3213
+ """Show the diff for *commit_hash*; `git show` runs on a worker thread."""
3214
+ self._load_commit_detail_worker(
3215
+ commit_hash=commit_hash, serial=self._new_request("commit-detail")
3216
+ )
3217
+
3218
+ @work(thread=True, exclusive=True, group="git-commit-detail")
3219
+ def _load_commit_detail_worker(self, *, commit_hash: str, serial: int) -> None:
3220
+ detail_text = self.git.show_commit(commit_hash)
3221
+ self.call_from_thread(self._apply_commit_detail, detail_text=detail_text, serial=serial)
3222
+
3223
+ def _apply_commit_detail(self, *, detail_text: str, serial: int) -> None:
3224
+ if not self._is_current_request("commit-detail", serial):
3225
+ return
3226
+ self.query_one("#detail", CommitDetail).show(detail_text)
3227
+
3228
+ def on_key(self, event) -> None:
3229
+ """Navigate between menus with left/right arrows while one is open.
3230
+
3231
+ A nested menu has no neighbours to cycle through, so there left means
3232
+ "back": it reopens the menu the submenu was opened from, the same way
3233
+ Escape does.
3234
+
3235
+ Dialog keys are not handled here: each dialog is a ModalScreen that
3236
+ owns its own keys, and Textual stops the binding chain at the modal
3237
+ (see `Screen._modal_binding_chain`), so no key can reach this app
3238
+ while one is open.
3239
+ """
3240
+ if self._open_menu_for_accelerator(event):
3241
+ return
3242
+ if event.key not in ("left", "right"):
3243
+ return
3244
+ if event.key == "left" and self._close_submenu_to_parent():
3245
+ event.stop()
3246
+ return
3247
+ current = self._open_menu_index()
3248
+ if current == -1:
3249
+ return
3250
+ event.stop()
3251
+ delta = 1 if event.key == "right" else -1
3252
+ self._show_menu_by_index((current + delta) % len(self.MENU_ORDER))
3253
+
3254
+ def on_click(self, event) -> None:
3255
+ """Close open menus when clicking outside them."""
3256
+ node = event.widget
3257
+ while node is not None:
3258
+ if isinstance(node, (DropdownMenu, MenuBar)):
3259
+ return
3260
+ node = node.parent
3261
+ self.action_close_menus()
3262
+
3263
+ # ------------------------------------------------------------------
3264
+ # Menu helpers
3265
+ # ------------------------------------------------------------------
3266
+
3267
+ def _open_menu_for_accelerator(self, event) -> bool:
3268
+ """Open the menu whose highlighted letter *event* names.
3269
+
3270
+ Alt+letter is the Turbo Vision way into a menu bar, and it works both
3271
+ from the app and while another menu is already open.
3272
+ """
3273
+ prefix = "alt+"
3274
+ if not event.key.startswith(prefix):
3275
+ return False
3276
+ char = event.key[len(prefix) :]
3277
+ if len(char) != 1:
3278
+ return False
3279
+ for index, (label, _) in enumerate(MENU_BAR):
3280
+ if _menu_mnemonic_key(label) == char.lower():
3281
+ event.stop()
3282
+ self._show_menu_by_index(index)
3283
+ return True
3284
+ return False
3285
+
3286
+ def _open_menu_index(self) -> int:
3287
+ """Return the index of the currently visible dropdown, or -1."""
3288
+ for i, menu_id in enumerate(self.MENU_ORDER):
3289
+ if self.query_one(f"#{_dropdown_id(menu_id)}", DropdownMenu).display:
3290
+ return i
3291
+ return -1
3292
+
3293
+ def _open_submenu(self) -> Submenu | None:
3294
+ """The nested menu that is currently open, if any."""
3295
+ registered = {submenu.dropdown_id: submenu for submenu in SUBMENUS}
3296
+ for dropdown in self.query(DropdownMenu):
3297
+ if dropdown.display and dropdown.id in registered:
3298
+ return registered[dropdown.id]
3299
+ return None
3300
+
3301
+ def _close_submenu_to_parent(self) -> bool:
3302
+ """Reopen the parent of the open nested menu; False when none is open.
3303
+
3304
+ A nested menu is reached through its parent, so both ways out of it —
3305
+ Escape and left — put that parent back on screen with the cursor on
3306
+ the entry that opened the submenu, rather than dropping the user out
3307
+ of the menus altogether.
3308
+ """
3309
+ submenu = self._open_submenu()
3310
+ if submenu is None or submenu.parent_id not in self.MENU_ORDER:
3311
+ return False
3312
+ self._show_menu_by_index(self.MENU_ORDER.index(submenu.parent_id))
3313
+ try:
3314
+ parent = self.query_one(f"#{_dropdown_id(submenu.parent_id)}", DropdownMenu)
3315
+ except WIDGET_LOOKUP_ERRORS:
3316
+ return True
3317
+ parent.highlight_action(submenu.action)
3318
+ return True
3319
+
3320
+ def _refresh_dropdown(self, menu_id: str) -> None:
3321
+ if menu_id == "menu_file":
3322
+ self.query_one("#dropdown_file", DropdownMenu).set_items(self._file_menu_items())
3323
+
3324
+ def _show_menu_by_index(self, index: int) -> None:
3325
+ """Close all menus and open the one at *index*."""
3326
+ menu_id = self.MENU_ORDER[index]
3327
+ self._refresh_dropdown(menu_id)
3328
+ label = self.query_one(f"#{menu_id}", MenuLabel)
3329
+ region = label.region
3330
+ for dd in self.query(DropdownMenu):
3331
+ dd.hide()
3332
+ for ml in self.query(MenuLabel):
3333
+ ml.remove_class("-active")
3334
+ label.add_class("-active")
3335
+ self.query_one(f"#{_dropdown_id(menu_id)}", DropdownMenu).show_at(region.x, region.y + 1)
3336
+
3337
+ def toggle_menu(self, menu_id: str, x: int, y: int) -> None:
3338
+ self._refresh_dropdown(menu_id)
3339
+ target = self.query_one(f"#{_dropdown_id(menu_id)}", DropdownMenu)
3340
+ if target.display:
3341
+ # Every dropdown, not just this one: a cascading submenu is open
3342
+ # alongside its parent and would be left behind on its own.
3343
+ for dd in self.query(DropdownMenu):
3344
+ dd.hide()
3345
+ self.query_one(f"#{menu_id}", MenuLabel).remove_class("-active")
3346
+ else:
3347
+ for dd in self.query(DropdownMenu):
3348
+ dd.hide()
3349
+ for ml in self.query(MenuLabel):
3350
+ ml.remove_class("-active")
3351
+ self.query_one(f"#{menu_id}", MenuLabel).add_class("-active")
3352
+ target.show_at(x, y)
3353
+
3354
+ # ------------------------------------------------------------------
3355
+ # Actions
3356
+ # ------------------------------------------------------------------
3357
+
3358
+ def action_open_menu(self) -> None:
3359
+ """F9: open the first menu."""
3360
+ self._show_menu_by_index(0)
3361
+
3362
+ def action_quit(self) -> None: # type: ignore[override]
3363
+ """F10: quit, killing any git read still in flight first.
3364
+
3365
+ Why: the reads run on thread workers, and a thread worker can only be
3366
+ waited for — Textual joins them during shutdown. So F10 pressed while
3367
+ the startup `git status` / `git log` are still running registered
3368
+ fine but changed nothing on screen until git returned, which on a
3369
+ large repo is seconds of an app that looks like it ignored the key.
3370
+ Ending the subprocesses ends the workers with it.
3371
+
3372
+ A pull or push in flight is left alone: it is a write, half of it is
3373
+ on the wire, and the user asked to close the app rather than to
3374
+ cancel the command. That quit waits, the way every quit used to.
3375
+ """
3376
+ if not self._remote_running:
3377
+ self.git.shutdown()
3378
+ self.exit()
3379
+
3380
+ def action_refresh(self) -> None:
3381
+ self.action_close_menus()
3382
+ switcher = self.query_one(ContentSwitcher)
3383
+ if switcher.current == "status-view" or switcher.current == "diff-view":
3384
+ self._load_status()
3385
+ else:
3386
+ self._load_commits()
3387
+ self.notify("Refreshed")
3388
+
3389
+ def action_view_status(self) -> None:
3390
+ self.action_close_menus()
3391
+ self._load_status()
3392
+ self.query_one(ContentSwitcher).current = "status-view"
3393
+
3394
+ def action_view_history(self) -> None:
3395
+ self.action_close_menus()
3396
+ self.query_one(ContentSwitcher).current = "history-view"
3397
+
3398
+ def action_short_file_list(self) -> None:
3399
+ self.action_close_menus()
3400
+ self._set_file_list_mode(MODE_SHORT)
3401
+
3402
+ def action_toggle_file_list(self) -> None:
3403
+ """F3: flip between the short and long file-list presentations.
3404
+
3405
+ The two modes are a visually unmistakable binary (inline diff panel vs.
3406
+ full-width list), so one key covers both directions. The File menu still
3407
+ offers each mode as its own entry for an unambiguous direct set.
3408
+
3409
+ F3 is the Norton Commander / MC "view" key, matching the F2/F9/F10
3410
+ bindings this app already uses. The previous Ctrl+S / Ctrl+W pair was
3411
+ unsafe: Ctrl+S is XOFF flow control (the same reason 8b9c467 moved
3412
+ commit-save to F2) and Ctrl+W is werase in the shell this app spawns
3413
+ for Ctrl+O, as well as close-tab in browser-hosted terminals.
3414
+ """
3415
+ if self.file_list_mode == MODE_LONG:
3416
+ self.action_short_file_list_inline_diff()
3417
+ else:
3418
+ self.action_long_file_list_full_screen_diff()
3419
+
3420
+ def action_short_file_list_inline_diff(self) -> None:
3421
+ """Switch to short mode, carrying an open full-screen diff
3422
+ back to the inline panel next to the file list."""
3423
+ self.action_close_menus()
3424
+ if self.file_list_mode == MODE_LONG:
3425
+ entry = self._diff_entry_if_full_screen_visible()
3426
+ if entry is not None:
3427
+ self._move_diff_between_modes(MODE_SHORT, entry)
3428
+ return
3429
+ self._set_file_list_mode(MODE_SHORT)
3430
+
3431
+ def action_long_file_list_full_screen_diff(self) -> None:
3432
+ """Switch to long mode, carrying the inline diff on to the
3433
+ full-screen view — but only when that diff is what the user is
3434
+ looking at.
3435
+
3436
+ The inline panel stays visible after Enter opens a diff, so "a diff
3437
+ is on screen" is not enough to tell "expand this diff" from "widen
3438
+ the list": with the focus back on the file list, F3 has to mean the
3439
+ mode switch it advertises. Focus is read before the menus close,
3440
+ because closing them hands focus back to the active view.
3441
+ """
3442
+ entry = self._focused_inline_diff_entry() if self.file_list_mode == MODE_SHORT else None
3443
+ self.action_close_menus()
3444
+ if entry is not None:
3445
+ self._move_diff_between_modes(MODE_LONG, entry)
3446
+ return
3447
+ self._set_file_list_mode(MODE_LONG)
3448
+
3449
+ def action_long_file_list(self) -> None:
3450
+ self.action_close_menus()
3451
+ self._set_file_list_mode(MODE_LONG)
3452
+
3453
+ def action_toggle_file_filter(self) -> None:
3454
+ """f: step to the next quick filter of the file list."""
3455
+ self.action_close_menus()
3456
+ index = FILE_LIST_FILTERS.index(self.file_list_filter)
3457
+ self._set_file_list_filter(FILE_LIST_FILTERS[(index + 1) % len(FILE_LIST_FILTERS)])
3458
+
3459
+ def action_file_filter_none(self) -> None:
3460
+ self.action_close_menus()
3461
+ self._set_file_list_filter(FILTER_NONE)
3462
+
3463
+ def action_file_filter_untracked_dirs(self) -> None:
3464
+ self.action_close_menus()
3465
+ self._set_file_list_filter(FILTER_UNTRACKED_DIRS)
3466
+
3467
+ def _set_file_list_filter(self, file_filter: str) -> None:
3468
+ """Apply *file_filter* and reload the file list under it.
3469
+
3470
+ The full-screen diff is left because the file it shows may be one the
3471
+ new filter folds into its directory; the highlight follows the file
3472
+ to that directory's row (see `GitFilelist.index_of`).
3473
+ """
3474
+ selected_entry = self._current_status_entry() or self._last_status_entry
3475
+ self.file_list_filter = file_filter
3476
+ self._update_status_header()
3477
+ self._leave_diff_view()
3478
+ self._load_status(restore=selected_entry)
3479
+
3480
+ def _set_file_list_mode(self, mode: str) -> None:
3481
+ selected_entry = self._current_status_entry() or self._last_status_entry
3482
+ self.file_list_mode = mode
3483
+ status_view = self.query_one("#status-view")
3484
+ if mode == MODE_LONG:
3485
+ status_view.add_class("long-mode")
3486
+ else:
3487
+ status_view.remove_class("long-mode")
3488
+ self._leave_diff_view()
3489
+ self._load_status(restore=selected_entry)
3490
+
3491
+ def _move_diff_between_modes(self, target_mode: str, entry: GitEntry) -> None:
3492
+ """Switch file-list mode while keeping the open diff visible, moving
3493
+ it from inline↔full-screen as appropriate."""
3494
+ self._last_status_entry = entry
3495
+ self.file_list_mode = target_mode
3496
+ status_view = self.query_one("#status-view")
3497
+ if target_mode == MODE_LONG:
3498
+ status_view.add_class("long-mode")
3499
+ else:
3500
+ status_view.remove_class("long-mode")
3501
+ # clear_diff=False: the status reload and the diff render are separate
3502
+ # workers, so letting the reload blank the panel would race the diff
3503
+ # we are deliberately carrying across the mode switch.
3504
+ self._load_status(restore=entry, clear_diff=False)
3505
+ switcher = self.query_one(ContentSwitcher)
3506
+ if target_mode == MODE_LONG:
3507
+ switcher.current = "diff-view"
3508
+ self._render_entry_into("diff-full", entry)
3509
+ else:
3510
+ switcher.current = "status-view"
3511
+ self.query_one("#diff-full", FileDiff).border_title = ""
3512
+ self._render_entry_into("file-diff", entry)
3513
+
3514
+ def _render_entry_into(self, widget_id: str, entry: GitEntry) -> None:
3515
+ """Show *entry* in the FileDiff with *widget_id*, as a diff or, for an
3516
+ untracked file, as raw content. Reads the file/diff off the UI thread.
3517
+
3518
+ Takes an id rather than the widget itself because the read is deferred:
3519
+ the widget is re-queried when the text comes back.
3520
+ """
3521
+ self._render_entry_worker(
3522
+ widget_id=widget_id, entry=entry, serial=self._new_request("diff")
3523
+ )
3524
+
3525
+ @work(thread=True, exclusive=True, group="git-diff")
3526
+ def _render_entry_worker(self, *, widget_id: str, entry: GitEntry, serial: int) -> None:
3527
+ if entry.code.is_untracked:
3528
+ text = self.git.load_file_content(entry)
3529
+ else:
3530
+ text = self.git.load_file_diff(entry)
3531
+ self.call_from_thread(
3532
+ self._apply_entry_diff, widget_id=widget_id, entry=entry, text=text, serial=serial
3533
+ )
3534
+
3535
+ def _apply_entry_diff(self, *, widget_id: str, entry: GitEntry, text: str, serial: int) -> None:
3536
+ if not self._is_current_request("diff", serial):
3537
+ return
3538
+ try:
3539
+ widget = self.query_one(f"#{widget_id}", FileDiff)
3540
+ except WIDGET_LOOKUP_ERRORS:
3541
+ return
3542
+ if entry.code.is_untracked:
3543
+ widget.show_content(text, entry.filename)
3544
+ else:
3545
+ widget.show(text, entry.filename)
3546
+
3547
+ def _current_status_entry(self) -> GitEntry | None:
3548
+ status_list = self.query_one("#status_list", StatusList)
3549
+ return self.status_filelist.entry_at(status_list.index)
3550
+
3551
+ def _restore_status_selection(self, entry: GitEntry) -> None:
3552
+ index = self.status_filelist.index_of(entry)
3553
+ if index is None:
3554
+ return
3555
+ self._last_status_entry = self.status_filelist.set_highlighted_index(index)
3556
+ status_list = self.query_one("#status_list", StatusList)
3557
+ old_index = status_list.index
3558
+ status_list.index = index
3559
+ if old_index == index:
3560
+ # Textual only runs watch_index when the reactive value changes.
3561
+ # When returning from diff-view, the list can already be on the
3562
+ # right row but have lost the visual highlight class.
3563
+ status_list.watch_index(old_index, index)
3564
+
3565
+ def _schedule_restore_selection(
3566
+ self, entry: GitEntry, *, refocus_active_view: bool = False
3567
+ ) -> None:
3568
+ """Defer highlight restoration until after the list is re-rendered.
3569
+
3570
+ Why: _load_status() calls ListView.clear() + append(), but Textual
3571
+ processes the prune messages asynchronously, so the cleared items
3572
+ linger in _nodes alongside the new ones. Setting list_view.index
3573
+ synchronously lands the -highlight class on a dying old item, and
3574
+ the matching new row is never highlighted.
3575
+ How to apply: call this instead of _restore_status_selection after
3576
+ any _load_status() / _render_status_rows() that rebuilt the list.
3577
+ """
3578
+ restore = (
3579
+ self._restore_status_selection_and_focus
3580
+ if refocus_active_view
3581
+ else self._restore_status_selection
3582
+ )
3583
+ self.call_after_refresh(restore, entry)
3584
+
3585
+ def _restore_status_selection_and_focus(self, entry: GitEntry) -> None:
3586
+ self._restore_status_selection(entry)
3587
+ self._focus_active_view_unless_dialog()
3588
+
3589
+ def _focus_active_view_unless_dialog(self) -> None:
3590
+ """Refocus the active view unless a dialog is on top of it.
3591
+
3592
+ This runs deferred (via call_after_refresh), and the commit flow opens
3593
+ the message dialog from inside the confirm callback — so by the time
3594
+ it fires, a dialog screen may already own the focus.
3595
+ """
3596
+ try:
3597
+ on_dialog = isinstance(self.screen, ModalScreen)
3598
+ except NO_SCREEN_ERRORS:
3599
+ on_dialog = False
3600
+ if not on_dialog:
3601
+ self._focus_active_view()
3602
+
3603
+ def _focused_inline_diff_entry(self) -> GitEntry | None:
3604
+ """The highlighted entry, but only while the inline diff panel is
3605
+ both visible and the focused widget."""
3606
+ inline_diff = self.query_one("#file-diff", FileDiff)
3607
+ if not inline_diff.display:
3608
+ return None
3609
+ try:
3610
+ focused = self.focused
3611
+ except NO_SCREEN_ERRORS:
3612
+ return None
3613
+ if focused is not inline_diff:
3614
+ return None
3615
+ return self._current_status_entry()
3616
+
3617
+ def _diff_entry_if_full_screen_visible(self) -> GitEntry | None:
3618
+ if self.query_one(ContentSwitcher).current != "diff-view":
3619
+ return None
3620
+ return self._current_status_entry()
3621
+
3622
+ def _leave_diff_view(self) -> None:
3623
+ switcher = self.query_one(ContentSwitcher)
3624
+ entry = self._current_status_entry() or self._last_status_entry
3625
+ if switcher.current == "diff-view":
3626
+ switcher.current = "status-view"
3627
+ self.query_one("#diff-full", FileDiff).border_title = ""
3628
+ if entry is not None:
3629
+ self._restore_status_selection(entry)
3630
+
3631
+ def action_stage_file(self) -> None:
3632
+ """S: ask before running `git add`. If multiple files are selected,
3633
+ batch-stage every selected file that has unstaged changes; otherwise
3634
+ fall back to the single highlighted file."""
3635
+ self.action_close_menus()
3636
+ selected = self.status_filelist.selected_entries()
3637
+ if selected:
3638
+ eligible = [e for e in selected if e.code.has_unstaged_changes]
3639
+ if not eligible:
3640
+ self.notify("No selected files have unstaged changes")
3641
+ return
3642
+ commands = [" ".join(self.git.stage_command_for(e)) for e in eligible]
3643
+ self._show_confirm(
3644
+ f"Stage {len(eligible)} file(s)?",
3645
+ commands,
3646
+ lambda: self._run_stage_batch(eligible),
3647
+ )
3648
+ return
3649
+ entry = self._current_status_entry()
3650
+ if entry is None:
3651
+ return
3652
+ if not entry.code.has_unstaged_changes:
3653
+ self.notify(f"{entry.filename} has no unstaged changes")
3654
+ return
3655
+ command = " ".join(self.git.stage_command_for(entry))
3656
+ self._show_confirm(
3657
+ f"Stage {entry.filename}?",
3658
+ [command],
3659
+ lambda: self._run_stage(entry),
3660
+ )
3661
+
3662
+ def action_unstage_file(self) -> None:
3663
+ """U: ask before running `git restore --staged`. If multiple files are
3664
+ selected, batch-unstage every selected file that has staged changes;
3665
+ otherwise fall back to the single highlighted file."""
3666
+ self.action_close_menus()
3667
+ selected = self.status_filelist.selected_entries()
3668
+ if selected:
3669
+ eligible = [e for e in selected if e.code.has_staged_changes]
3670
+ if not eligible:
3671
+ self.notify("No selected files have staged changes")
3672
+ return
3673
+ commands = [" ".join(self.git.unstage_command_for(e)) for e in eligible]
3674
+ self._show_confirm(
3675
+ f"Unstage {len(eligible)} file(s)?",
3676
+ commands,
3677
+ lambda: self._run_unstage_batch(eligible),
3678
+ )
3679
+ return
3680
+ entry = self._current_status_entry()
3681
+ if entry is None:
3682
+ return
3683
+ if not entry.code.has_staged_changes:
3684
+ self.notify(f"{entry.filename} has no staged changes")
3685
+ return
3686
+ command = " ".join(self.git.unstage_command_for(entry))
3687
+ self._show_confirm(
3688
+ f"Unstage {entry.filename}?",
3689
+ [command],
3690
+ lambda: self._run_unstage(entry),
3691
+ )
3692
+
3693
+ def action_restore_file(self) -> None:
3694
+ """F7: ask before resetting file(s) to their HEAD state.
3695
+
3696
+ Covers every status where a restore has a well-defined meaning:
3697
+ modified/deleted/typechange/renamed/copied (staged or unstaged)
3698
+ become a single `git restore --source=HEAD --staged --worktree`;
3699
+ added-in-index become `git restore --staged` + `git clean -f`;
3700
+ untracked become `git clean -f`. Ignored and unmerged files are
3701
+ filtered out because restoring them isn't unambiguous.
3702
+ """
3703
+ self.action_close_menus()
3704
+ selected = self.status_filelist.selected_entries()
3705
+ if selected:
3706
+ eligible = [e for e in selected if e.code.is_restorable]
3707
+ if not eligible:
3708
+ self.notify("No selected files can be restored")
3709
+ return
3710
+ commands: list[str] = []
3711
+ for entry in eligible:
3712
+ for argv in self.git.restore_commands(entry):
3713
+ commands.append(" ".join(argv))
3714
+ self._show_confirm(
3715
+ f"Restore {len(eligible)} file(s) to HEAD?",
3716
+ commands,
3717
+ lambda: self._run_restore_batch(eligible),
3718
+ )
3719
+ return
3720
+ entry = self._current_status_entry()
3721
+ if entry is None:
3722
+ return
3723
+ if not entry.code.is_restorable:
3724
+ self.notify(f"{entry.filename} cannot be restored")
3725
+ return
3726
+ commands = [" ".join(argv) for argv in self.git.restore_commands(entry)]
3727
+ self._show_confirm(
3728
+ f"Restore {entry.filename} to HEAD?",
3729
+ commands,
3730
+ lambda: self._run_restore(entry),
3731
+ )
3732
+
3733
+ def action_delete_file(self) -> None:
3734
+ """DELETE: ask before removing file(s).
3735
+
3736
+ Files are grouped by status code in the confirmation dialog, with
3737
+ the operation that runs for that status shown once per group above
3738
+ the affected filenames. The command shape comes from
3739
+ `git.delete_commands` so the dialog and the executor stay in sync.
3740
+ """
3741
+ self.action_close_menus()
3742
+ selected = self.status_filelist.selected_entries()
3743
+ if selected:
3744
+ eligible = [e for e in selected if e.code.is_deletable]
3745
+ multi = True
3746
+ else:
3747
+ entry = self._current_status_entry()
3748
+ eligible = [entry] if entry is not None and entry.code.is_deletable else []
3749
+ multi = False
3750
+ if not eligible:
3751
+ self.notify("No files can be deleted")
3752
+ return
3753
+ groups: dict[GitCode, list[GitEntry]] = {}
3754
+ for entry in eligible:
3755
+ groups.setdefault(entry.code, []).append(entry)
3756
+ commands = self._format_delete_groups(groups)
3757
+ prompt = f"Delete {len(eligible)} file(s)?" if multi else f"Delete {eligible[0].filename}?"
3758
+ self._show_confirm(
3759
+ prompt,
3760
+ commands,
3761
+ lambda: self._run_delete_batch(eligible),
3762
+ )
3763
+
3764
+ def _format_delete_groups(self, groups: dict[GitCode, list[GitEntry]]) -> list[str]:
3765
+ """Render one section per status group for the confirm dialog."""
3766
+ lines: list[str] = []
3767
+ for index, (code, entries) in enumerate(groups.items()):
3768
+ if index > 0:
3769
+ lines.append("")
3770
+ lines.append(f"-- {code.long_description} --")
3771
+ sample_name = self.git.entry_command_filename(entries[0])
3772
+ for argv in self.git.delete_commands(entries[0]):
3773
+ template = ["<file>" if part == sample_name else part for part in argv]
3774
+ lines.append(f" {' '.join(template)}")
3775
+ for entry in entries:
3776
+ lines.append(f" {entry.filename}")
3777
+ return lines
3778
+
3779
+ def _show_confirm(
3780
+ self,
3781
+ prompt: str,
3782
+ commands: list[str],
3783
+ on_confirm: Callable[[], None],
3784
+ on_cancel: Callable[[], object] | None = None,
3785
+ *,
3786
+ fill_height: bool = False,
3787
+ ) -> None:
3788
+ """Ask for Y/N confirmation, running `on_confirm` only if confirmed.
3789
+
3790
+ N (or Esc) hands focus back to the active view unless `on_cancel` says
3791
+ otherwise — the commit confirmation uses it to reopen the message
3792
+ dialog instead of throwing the message away.
3793
+
3794
+ `fill_height` lets the command list grow to the screen height (see
3795
+ `ConfirmDialog`); the commit prompts use it to show every file.
3796
+ """
3797
+
3798
+ def resolved(confirmed: bool | None) -> None:
3799
+ if confirmed:
3800
+ on_confirm()
3801
+ elif on_cancel is not None:
3802
+ on_cancel()
3803
+ else:
3804
+ self._focus_active_view()
3805
+
3806
+ self.push_screen(
3807
+ ConfirmDialog(prompt=prompt, commands=commands, fill_height=fill_height),
3808
+ callback=resolved,
3809
+ )
3810
+
3811
+ def _run_stage(self, entry: GitEntry) -> None:
3812
+ result = self.git.stage_entry(entry)
3813
+ self._report_git_result(result, f"Staged {entry.filename}", "git add failed")
3814
+ self._reload_status_keeping_selection(entry)
3815
+
3816
+ def _run_unstage(self, entry: GitEntry) -> None:
3817
+ result = self.git.unstage_entry(entry)
3818
+ self._report_git_result(result, f"Unstaged {entry.filename}", "git restore --staged failed")
3819
+ self._reload_status_keeping_selection(entry)
3820
+
3821
+ def _run_restore(self, entry: GitEntry) -> None:
3822
+ results = self.git.restore_file(entry)
3823
+ failed = next((r for r in results if getattr(r, "returncode", 0) != 0), None)
3824
+ if failed is not None:
3825
+ stderr = (getattr(failed, "stderr", "") or "").strip()
3826
+ detail = f": {stderr}" if stderr else ""
3827
+ self.notify(f"git restore failed{detail}")
3828
+ else:
3829
+ self.notify(f"Restored {entry.filename}")
3830
+ self._reload_status_keeping_selection(entry)
3831
+
3832
+ def _run_restore_batch(self, entries: list[GitEntry]) -> None:
3833
+ failures: list[str] = []
3834
+ for entry in entries:
3835
+ results = self.git.restore_file(entry)
3836
+ if any(getattr(r, "returncode", 0) != 0 for r in results):
3837
+ failures.append(entry.filename)
3838
+ if failures:
3839
+ self.notify(f"git restore failed for {len(failures)} file(s): {', '.join(failures)}")
3840
+ else:
3841
+ self.notify(f"Restored {len(entries)} file(s)")
3842
+ self._load_status(restore=self._current_status_entry())
3843
+
3844
+ def _run_delete_batch(self, entries: list[GitEntry]) -> None:
3845
+ failures: list[str] = []
3846
+ for entry in entries:
3847
+ results = self.git.delete_file(entry)
3848
+ if any(getattr(r, "returncode", 0) != 0 for r in results):
3849
+ failures.append(entry.filename)
3850
+ if failures:
3851
+ self.notify(f"delete failed for {len(failures)} file(s): {', '.join(failures)}")
3852
+ else:
3853
+ self.notify(f"Deleted {len(entries)} file(s)")
3854
+ self._load_status(restore=self._current_status_entry())
3855
+
3856
+ def _run_stage_batch(self, entries: list[GitEntry]) -> None:
3857
+ self._run_git_batch(entries, self.git.stage_entry, "Staged", "git add")
3858
+
3859
+ def _run_unstage_batch(self, entries: list[GitEntry]) -> None:
3860
+ self._run_git_batch(entries, self.git.unstage_entry, "Unstaged", "git restore --staged")
3861
+
3862
+ def _run_git_batch(
3863
+ self,
3864
+ entries: list[GitEntry],
3865
+ run_one: Callable[[GitEntry], object],
3866
+ success_verb: str,
3867
+ failure_prefix: str,
3868
+ ) -> None:
3869
+ failures: list[str] = []
3870
+ for entry in entries:
3871
+ result = run_one(entry)
3872
+ if getattr(result, "returncode", 0) != 0:
3873
+ failures.append(entry.filename)
3874
+ if failures:
3875
+ self.notify(
3876
+ f"{failure_prefix} failed for {len(failures)} file(s): {', '.join(failures)}"
3877
+ )
3878
+ else:
3879
+ self.notify(f"{success_verb} {len(entries)} file(s)")
3880
+ self._load_status(restore=self._current_status_entry())
3881
+
3882
+ def _report_git_result(self, result, success_message: str, failure_prefix: str) -> None:
3883
+ returncode = getattr(result, "returncode", 0)
3884
+ if returncode != 0:
3885
+ stderr = (getattr(result, "stderr", "") or "").strip()
3886
+ detail = f": {stderr}" if stderr else ""
3887
+ self.notify(f"{failure_prefix}{detail}")
3888
+ else:
3889
+ self.notify(success_message)
3890
+
3891
+ def _reload_status_keeping_selection(self, entry: GitEntry) -> None:
3892
+ self._load_status(restore=entry, refocus=True)
3893
+
3894
+ def action_commit_files(self) -> None:
3895
+ """C: commit the highlighted file or every selected file.
3896
+
3897
+ The flow:
3898
+ 1. Determine the target files (selection, or highlighted fallback).
3899
+ 2. If any target has unstaged changes, open a Y/N confirm with the
3900
+ `git add` commands that would stage them.
3901
+ 3. After (optional) stage confirmation, unstage any files that are
3902
+ currently staged but NOT in the commit selection (recorded so
3903
+ they can be re-staged later).
3904
+ 4. Show a multi-line commit message dialog, prefilled with the
3905
+ branch name when `branch_prefix_in_commits` is enabled.
3906
+ 5. On submit: confirm the commit, showing every command it will
3907
+ run, and only then run them and restore the previously-staged
3908
+ externals. On Esc: undo both index changes step 2 and 3 made —
3909
+ restore the externals and unstage what we staged — so a
3910
+ cancelled commit leaves the index exactly as it found it.
3911
+ """
3912
+ self.action_close_menus()
3913
+
3914
+ targets = self.status_filelist.selected_entries()
3915
+ if not targets:
3916
+ entry = self._current_status_entry()
3917
+ if entry is None:
3918
+ self.notify("Nothing to commit")
3919
+ return
3920
+ targets = [entry]
3921
+
3922
+ committable = [
3923
+ e for e in targets if e.code.has_staged_changes or e.code.has_unstaged_changes
3924
+ ]
3925
+ if not committable:
3926
+ self.notify("No changes to commit in selection")
3927
+ return
3928
+
3929
+ to_stage = [e for e in targets if e.code.has_unstaged_changes]
3930
+ target_filenames = {e.filename for e in targets}
3931
+ # A submodule whose inner files we're committing will have its gitlink
3932
+ # re-staged automatically by commit_with_selection — never unstage it
3933
+ # as an "external" or we'd lose the pointer bump.
3934
+ selected_subs = {e.submodule for e in targets if e.submodule}
3935
+ externals = sorted(
3936
+ f for f in self.git.staged_filenames() - target_filenames if f not in selected_subs
3937
+ )
3938
+
3939
+ # Only entries whose index state we create from nothing can be rolled
3940
+ # back on cancel: an entry that already had staged changes was staged
3941
+ # *partially*, and `git restore --staged` would throw that away rather
3942
+ # than put it back.
3943
+ undo_stage = [e for e in to_stage if not e.code.has_staged_changes]
3944
+
3945
+ def proceed() -> None:
3946
+ for entry in to_stage:
3947
+ self.git.stage_entry(entry)
3948
+ for filename in externals:
3949
+ self.git.unstage_file(filename)
3950
+ self._begin_commit_message(targets, externals, staged=undo_stage)
3951
+
3952
+ if to_stage:
3953
+ commands = [" ".join(self.git.stage_command_for(e)) for e in to_stage]
3954
+ self._show_confirm(
3955
+ f"Stage {len(to_stage)} file(s) for commit?",
3956
+ commands,
3957
+ proceed,
3958
+ fill_height=True,
3959
+ )
3960
+ return
3961
+ proceed()
3962
+
3963
+ def _begin_commit_message(
3964
+ self, targets: list[GitEntry], externals: list[str], staged: list[GitEntry] | None = None
3965
+ ) -> None:
3966
+ """Open the multi-line commit message dialog after staging is resolved.
3967
+
3968
+ `staged` are the entries this flow just staged and would have to
3969
+ unstage again if the commit is cancelled.
3970
+ """
3971
+ self._pending_commit_externals = list(externals)
3972
+ self._pending_commit_staged = list(staged or [])
3973
+ self._pending_commit_targets = list(targets)
3974
+ branch = self._commit_branch_prefix()
3975
+ prefill = f"{branch}: " if branch else ""
3976
+ self._edit_commit_message(prefill)
3977
+
3978
+ def _edit_commit_message(self, message: str) -> None:
3979
+ """Open `message` in the editor the settings name, and hand the
3980
+ result on to `_commit_result` as the commit dialog would.
3981
+
3982
+ The built-in dialog is also the fallback whenever the external editor
3983
+ can't be used — not configured after all, or a terminal that can't be
3984
+ handed over — so the commit the user started is never lost to a
3985
+ setting.
3986
+ """
3987
+ argv = self._commit_editor_argv()
3988
+ if argv is not None and self._commit_in_external_editor(argv=argv, message=message):
3989
+ return
3990
+ self.push_screen(CommitDialog(prefill=message), callback=self._commit_result)
3991
+
3992
+ def _commit_editor_argv(self) -> list[str] | None:
3993
+ """The external editor's command line, split the way a shell would,
3994
+ or None for the built-in dialog.
3995
+
3996
+ A setting that names an external editor without one — $EDITOR unset,
3997
+ an empty or unparseable command — says so and falls back to the
3998
+ dialog.
3999
+ """
4000
+ settings = self._load_settings()
4001
+ editor = _setting_str(settings, "commit_editor", COMMIT_EDITOR_BUILTIN)
4002
+ if editor == COMMIT_EDITOR_ENVIRONMENT:
4003
+ command = os.environ.get("EDITOR", "")
4004
+ missing = "$EDITOR is not set"
4005
+ elif editor == COMMIT_EDITOR_COMMAND:
4006
+ command = _setting_str(settings, "commit_editor_command", "")
4007
+ missing = "No editor command configured (Options → Settings)"
4008
+ else:
4009
+ return None
4010
+ if not command.strip():
4011
+ self.notify(f"{missing}; using the built-in editor")
4012
+ return None
4013
+ try:
4014
+ return shlex.split(command)
4015
+ except ValueError:
4016
+ self.notify(f"Could not read the editor command: {command}; using the built-in editor")
4017
+ return None
4018
+
4019
+ def _commit_in_external_editor(self, *, argv: list[str], message: str) -> bool:
4020
+ """Edit the commit message in `argv` on the previous screen.
4021
+
4022
+ The app is suspended for the length of the edit, the way `Ctrl+O`
4023
+ suspends it for a shell. An empty message — or one that is nothing
4024
+ but the branch prefix it was opened with — cancels the commit, as
4025
+ does an editor that exits with a failure status; git reads both the
4026
+ same way.
4027
+
4028
+ Returns False when nothing was edited — the terminal can't be
4029
+ suspended, or the editor couldn't be started — and the caller opens
4030
+ the built-in dialog instead. Only entering the suspension is guarded,
4031
+ as in `_draft_on_previous_screen()`.
4032
+ """
4033
+ filenames = [entry.filename for entry in self._pending_commit_targets]
4034
+ with ExitStack() as suspension:
4035
+ try:
4036
+ suspension.enter_context(self.suspend())
4037
+ except (OSError, SuspendNotSupported, RuntimeError):
4038
+ # RuntimeError: an app with no driver declines to suspend by
4039
+ # never yielding.
4040
+ self.notify("Can't hand the terminal to an editor here; using the built-in editor")
4041
+ return False
4042
+ edited = run_commit_editor(
4043
+ argv=argv, message=message, filenames=filenames, cwd=self.repo_path
4044
+ )
4045
+ if not edited.started:
4046
+ self.notify(f"{edited.detail}; using the built-in editor")
4047
+ return False
4048
+ if edited.message is None:
4049
+ self.notify(edited.detail)
4050
+ self._commit_result(None)
4051
+ return True
4052
+ branch = self._commit_branch_prefix()
4053
+ if not edited.message or (branch and edited.message == f"{branch}:"):
4054
+ self._commit_result(None)
4055
+ return True
4056
+ self._commit_result(edited.message)
4057
+ return True
4058
+
4059
+ def _commit_branch_prefix(self) -> str:
4060
+ """The branch name commit messages are prefixed with, or "" when the
4061
+ setting is off (or the branch can't be read)."""
4062
+ settings = self._load_settings()
4063
+ if not bool(settings.get("branch_prefix_in_commits", False)):
4064
+ return ""
4065
+ return self.git.branch_name() or ""
4066
+
4067
+ def _with_branch_prefix(self, message: str) -> str:
4068
+ """`message` with the branch prefix put in front of it when the
4069
+ setting asks for one and the message doesn't already carry it.
4070
+
4071
+ A drafting tool knows nothing about that setting, so what it hands
4072
+ back has to be prefixed here — unless it happened to open with the
4073
+ branch name anyway, e.g. because the prompt told it to.
4074
+ """
4075
+ branch = self._commit_branch_prefix()
4076
+ if not branch or message.startswith(branch):
4077
+ return message
4078
+ return f"{branch}: {message}"
4079
+
4080
+ def request_commit_draft(self, dialog: CommitDialog) -> None:
4081
+ """Draft button / F4 in the commit dialog: run the configured tool.
4082
+
4083
+ It is invoked as `<command> <prompt>` — the command line from the
4084
+ settings split the way a shell would, with the prompt template and
4085
+ the files being committed handed over as one final argument.
4086
+
4087
+ The run happens on the previous screen, exactly where `Ctrl+O` puts
4088
+ a shell: there is no UI to keep responsive while the app is
4089
+ suspended, so it runs inline. A terminal that can't be suspended
4090
+ falls back to the worker, which runs the same tool off the UI thread
4091
+ with only the dialog's log to show for it.
4092
+ """
4093
+ settings = self._load_settings()
4094
+ command = _setting_str(settings, "commit_draft_command", COMMIT_DRAFT_COMMAND_DEFAULT)
4095
+ if not command.strip():
4096
+ self.notify("No commit message tool configured (Options → Settings)")
4097
+ return
4098
+ try:
4099
+ argv = shlex.split(command)
4100
+ except ValueError:
4101
+ argv = []
4102
+ if not argv:
4103
+ self.notify(f"Could not read the commit message tool command: {command}")
4104
+ return
4105
+ template = _setting_str(settings, "commit_draft_prompt", COMMIT_DRAFT_PROMPT_DEFAULT)
4106
+ filenames = [entry.filename for entry in self._pending_commit_targets]
4107
+ prompt = commit_draft_prompt(template, filenames)
4108
+ dialog.set_drafting(True)
4109
+ if self._draft_on_previous_screen(dialog=dialog, argv=argv, prompt=prompt):
4110
+ return
4111
+ self._commit_draft_worker(dialog=dialog, argv=argv, prompt=prompt)
4112
+
4113
+ def _draft_on_previous_screen(
4114
+ self, *, dialog: CommitDialog, argv: list[str], prompt: str
4115
+ ) -> bool:
4116
+ """Run the drafting tool on the previous screen, as `Ctrl+O` does.
4117
+
4118
+ The tool is the one thing this app runs that has something to say
4119
+ while it works, so it says it on the terminal the TUI was started
4120
+ from rather than into a six-row pane: the app is suspended for the
4121
+ length of the run and every line is echoed as it is read. What it
4122
+ printed is still on that screen afterwards, so `Ctrl+O` from the main
4123
+ screen scrolls back to the whole run long after the commit.
4124
+
4125
+ Showing the output doesn't give up capturing it — the lines are still
4126
+ parsed into the commit message, or the reason there isn't one — but
4127
+ the screen is the only place they are *shown*. The dialog's log pane
4128
+ stays empty and hidden on this path: the user has just watched the
4129
+ whole run, and repeating it under the message box is noise around the
4130
+ one thing the dialog is for.
4131
+
4132
+ Returns False when the terminal can't be suspended: nothing has run
4133
+ at that point, and the caller falls back to the worker. Only entering
4134
+ the suspension is guarded — a stack rather than a plain `with` — so
4135
+ that a failure inside the run can't be read as "never suspended" and
4136
+ send the same tool off to the worker for a second run.
4137
+ """
4138
+
4139
+ def echo(line: str) -> None:
4140
+ self._screen.write_previous_screen(f"{line}\n")
4141
+
4142
+ with ExitStack() as suspension:
4143
+ try:
4144
+ suspension.enter_context(self.suspend())
4145
+ except (OSError, SuspendNotSupported, RuntimeError):
4146
+ # RuntimeError is the context manager never yielding, which
4147
+ # is how an app with no driver — one that isn't running —
4148
+ # declines to suspend.
4149
+ return False
4150
+ # The command line as a shell would show it, so what ran is part
4151
+ # of the scrollback and not just its output.
4152
+ self._screen.write_previous_screen(f"$ {shlex.join([*argv, prompt])}\n")
4153
+ drafted = run_commit_draft(argv=argv, prompt=prompt, cwd=self.repo_path, on_output=echo)
4154
+ self._screen.write_previous_screen("\n")
4155
+ self._apply_commit_draft(dialog=dialog, drafted=drafted)
4156
+ return True
4157
+
4158
+ @work(thread=True, exclusive=True, group="commit-draft")
4159
+ def _commit_draft_worker(self, *, dialog: CommitDialog, argv: list[str], prompt: str) -> None:
4160
+ """The fallback for a terminal that can't be suspended: the same run
4161
+ with the TUI still on screen, so it has to stay off the UI thread and
4162
+ the dialog's log is the only place the output shows."""
4163
+ drafted = run_commit_draft(
4164
+ argv=argv,
4165
+ prompt=prompt,
4166
+ cwd=self.repo_path,
4167
+ on_output=lambda line: self._post_draft_output(dialog=dialog, line=line),
4168
+ )
4169
+ self.call_from_thread(self._apply_commit_draft, dialog=dialog, drafted=drafted)
4170
+
4171
+ def _post_draft_output(self, *, dialog: CommitDialog, line: str) -> None:
4172
+ """Hand one line of the tool's output to the dialog from the worker.
4173
+
4174
+ Reached from the worker thread and from the stderr reader inside
4175
+ `run_commit_draft`, so it goes through `call_from_thread` like every
4176
+ other worker result. A run that outlives the app — the user quit
4177
+ while the tool was still going — has nowhere to put the line, and
4178
+ dropping it is the whole of the recovery.
4179
+ """
4180
+ try:
4181
+ self.call_from_thread(dialog.append_output, line)
4182
+ except RuntimeError:
4183
+ pass
4184
+
4185
+ def _apply_commit_draft(self, *, dialog: CommitDialog, drafted: DraftResult) -> None:
4186
+ """Put what the tool produced into the commit dialog.
4187
+
4188
+ With "Parse suggestions from numbered list" on, the output is read as
4189
+ a numbered list first: more than one item opens the picker, exactly
4190
+ one goes straight in (there is nothing to choose between), and none
4191
+ falls back to the whole output — a tool that ignored the request for
4192
+ a list still wrote something, and dropping it would be worse than
4193
+ handing it over.
4194
+ """
4195
+ dialog.set_drafting(False)
4196
+ if not drafted.ok:
4197
+ self.notify(drafted.text)
4198
+ return
4199
+ if self._parse_suggestions_enabled():
4200
+ suggestions = parse_numbered_suggestions(drafted.text)
4201
+ if len(suggestions) > 1:
4202
+ self.push_screen(
4203
+ SuggestionDialog(suggestions),
4204
+ callback=lambda picked: self._apply_commit_suggestion(
4205
+ dialog=dialog, picked=picked
4206
+ ),
4207
+ )
4208
+ return
4209
+ if suggestions:
4210
+ dialog.set_text(self._with_branch_prefix(suggestions[0]))
4211
+ return
4212
+ self.notify("No numbered suggestions found; using the whole output")
4213
+ dialog.set_text(self._with_branch_prefix(drafted.text))
4214
+
4215
+ def _parse_suggestions_enabled(self) -> bool:
4216
+ return bool(self._load_settings().get("commit_draft_parse_suggestions", False))
4217
+
4218
+ def _apply_commit_suggestion(self, *, dialog: CommitDialog, picked: str | None) -> None:
4219
+ """Picker callback: load the choice into the message, or leave the
4220
+ message alone when the user backed out. Either way the drafting tool's
4221
+ output is still in the dialog's log, so nothing is lost by cancelling.
4222
+ """
4223
+ if picked is None:
4224
+ return
4225
+ dialog.set_text(self._with_branch_prefix(picked))
4226
+
4227
+ def _restore_externals(self) -> None:
4228
+ for filename in self._pending_commit_externals:
4229
+ self.git.stage_file(filename)
4230
+ self._pending_commit_externals = []
4231
+
4232
+ def _unstage_pending(self) -> None:
4233
+ """Undo the `git add`s this commit flow ran (cancel path only).
4234
+
4235
+ Without this the files stay staged, and a second `C` on the same
4236
+ selection sees nothing left to stage — so the "Stage N file(s) for
4237
+ commit?" question never comes back after a cancelled commit.
4238
+ """
4239
+ for entry in self._pending_commit_staged:
4240
+ self.git.unstage_entry(entry)
4241
+ self._pending_commit_staged = []
4242
+
4243
+ def _commit_result(self, message: str | None) -> None:
4244
+ """Dialog callback: a message means confirm-then-commit, None means
4245
+ the user cancelled and the externals we unstaged must go back."""
4246
+ if message is None:
4247
+ self._commit_cancel()
4248
+ else:
4249
+ self._confirm_commit(message)
4250
+
4251
+ def _confirm_commit(self, message: str) -> None:
4252
+ """Show what the commit will run and ask before running it.
4253
+
4254
+ This is the last point at which the files going into the commit are
4255
+ visible — after it, `commit_with_selection` walks the submodules and
4256
+ commits. The command list comes from `commit_with_selection_commands`
4257
+ so the popup and the executor can't drift apart, with the message
4258
+ shortened to its subject line: the popup lists commands one per row,
4259
+ and a multi-line `-m` argument would break that.
4260
+
4261
+ N reopens the message dialog on the message just written rather than
4262
+ dropping it, so backing out of the confirmation costs nothing.
4263
+ """
4264
+ targets = list(self._pending_commit_targets)
4265
+ preview = _commit_message_subject(message)
4266
+ commands = [
4267
+ " ".join(argv) for argv in self.git.commit_with_selection_commands(targets, preview)
4268
+ ]
4269
+ self._show_confirm(
4270
+ f"Commit {len(targets)} file(s)?",
4271
+ commands,
4272
+ lambda: self._commit_submit(message),
4273
+ lambda: self._edit_commit_message(message),
4274
+ fill_height=True,
4275
+ )
4276
+
4277
+ def _commit_submit(self, message: str) -> None:
4278
+ result = self.git.commit_with_selection(self._pending_commit_targets, message)
4279
+ self._pending_commit_targets = []
4280
+ self._pending_commit_staged = []
4281
+ self._restore_externals()
4282
+ returncode = getattr(result, "returncode", 0)
4283
+ if returncode != 0:
4284
+ stderr = (getattr(result, "stderr", "") or "").strip()
4285
+ detail = f": {stderr}" if stderr else ""
4286
+ self.notify(f"git commit failed{detail}")
4287
+ else:
4288
+ self.notify("Committed")
4289
+ self._load_status()
4290
+ self._load_commits()
4291
+ self._focus_active_view()
4292
+
4293
+ def _commit_cancel(self) -> None:
4294
+ self._pending_commit_targets = []
4295
+ self._unstage_pending()
4296
+ self._restore_externals()
4297
+ self._load_status()
4298
+ self.notify("Commit cancelled")
4299
+ self._focus_active_view()
4300
+
4301
+ def action_clear_selection(self) -> None:
4302
+ """Ctrl+U: clear all file selections in the status view."""
4303
+ self.action_close_menus()
4304
+ if self.query_one(ContentSwitcher).current != "status-view":
4305
+ return
4306
+ if not self.status_filelist.selected_filenames:
4307
+ return
4308
+ self.status_filelist.clear_selections()
4309
+ self._apply_selection_classes()
4310
+
4311
+ # ------------------------------------------------------------------
4312
+ # Stash and remote commands
4313
+ #
4314
+ # All four ask first, for the same reason the file commands do: the
4315
+ # confirmation is the last place the exact command line is visible, and
4316
+ # each of these moves work the user cannot see from the file list —
4317
+ # `stash` empties the worktree, `stash pop` refills it (and can conflict),
4318
+ # `pull` moves HEAD, and `push` is the one that leaves the machine. What
4319
+ # none of them get is a second, free-text prompt: a stash message or a
4320
+ # remote to choose is a shell's job, and Ctrl+O is next to these keys.
4321
+ # ------------------------------------------------------------------
4322
+
4323
+ def action_stash(self) -> None:
4324
+ """z: ask before `git stash push`.
4325
+
4326
+ A selection stashes those files and nothing else; with nothing
4327
+ selected the whole worktree goes, because "stash the file under the
4328
+ cursor" is not what anyone reaches for stash to do. Submodule entries
4329
+ drop out of a path-limited stash — the pathspec would name a path in
4330
+ another repository — and the dialog says how many were left behind.
4331
+ """
4332
+ self.action_close_menus()
4333
+ if not self.status_filelist:
4334
+ self.notify("Nothing to stash")
4335
+ return
4336
+ selected = self.status_filelist.selected_entries()
4337
+ filenames: list[str] | None = None
4338
+ prompt = "Stash all changes?"
4339
+ notes: list[str] = []
4340
+ if selected:
4341
+ eligible = [entry for entry in selected if entry.submodule is None]
4342
+ if not eligible:
4343
+ self.notify("Cannot stash files inside a submodule")
4344
+ return
4345
+ filenames = [entry.filename for entry in eligible]
4346
+ prompt = f"Stash {len(filenames)} file(s)?"
4347
+ skipped = len(selected) - len(eligible)
4348
+ if skipped:
4349
+ notes = ["", f"-- {skipped} file(s) inside a submodule are left alone --"]
4350
+ command = " ".join(self.git.stash_command(filenames=filenames))
4351
+ self._show_confirm(
4352
+ prompt,
4353
+ [command, *notes],
4354
+ lambda: self._run_stash(filenames=filenames),
4355
+ )
4356
+
4357
+ def _run_stash(self, *, filenames: list[str] | None) -> None:
4358
+ result = self.git.stash(filenames=filenames)
4359
+ self._report_git_result(result, "Stashed changes", "git stash failed")
4360
+ self._load_status()
4361
+
4362
+ def action_stash_pop(self) -> None:
4363
+ """Shift+Z: ask before `git stash pop`.
4364
+
4365
+ The prompt names the entry that would be reapplied rather than saying
4366
+ "the stash": which one is on top is exactly what a user who stashed
4367
+ twice can't remember. An empty stash is reported instead of opening a
4368
+ dialog over a command that would only fail.
4369
+ """
4370
+ self.action_close_menus()
4371
+ entries = self.git.stash_entries()
4372
+ if not entries:
4373
+ self.notify("No stash entries to pop")
4374
+ return
4375
+ notes: list[str] = []
4376
+ if len(entries) > 1:
4377
+ notes = ["", f"-- {len(entries)} entries stashed; pop takes the newest --"]
4378
+ command = " ".join(self.git.stash_pop_command())
4379
+ self._show_confirm(
4380
+ f"Pop {_truncate_to_width(entries[0], STASH_PROMPT_MAX_WIDTH)}?",
4381
+ [command, *notes],
4382
+ self._run_stash_pop,
4383
+ )
4384
+
4385
+ def _run_stash_pop(self) -> None:
4386
+ result = self.git.stash_pop()
4387
+ self._report_git_result(result, "Popped the newest stash entry", "git stash pop failed")
4388
+ self._load_status()
4389
+
4390
+ def action_pull(self) -> None:
4391
+ """p: ask before `git pull --ff-only`.
4392
+
4393
+ Both refusals ahead of the dialog are things git would only report
4394
+ after the fact: with no remote there is nothing to pull from, and
4395
+ without an upstream `--ff-only` has no ref to fast-forward to.
4396
+ """
4397
+ self.action_close_menus()
4398
+ if not self._remote_available():
4399
+ return
4400
+ upstream = self.git.upstream_branch()
4401
+ if upstream is None:
4402
+ branch = self.git.branch_name() or "HEAD"
4403
+ self.notify(f"{branch} has no upstream branch — push it first (Shift+P)")
4404
+ return
4405
+ command = " ".join(self.git.pull_command())
4406
+ self._show_confirm(
4407
+ f"Pull from {upstream}?",
4408
+ [command, *self._submodule_mismatch_notes()],
4409
+ self._run_pull,
4410
+ )
4411
+
4412
+ def action_push(self) -> None:
4413
+ """Shift+P: ask before `git push`.
4414
+
4415
+ The prompt distinguishes the two shapes `push_command` can take,
4416
+ because pushing a branch that has no upstream publishes it on the
4417
+ remote for the first time — a bigger step than updating one that is
4418
+ already there, and the only place to say so is the question.
4419
+ """
4420
+ self.action_close_menus()
4421
+ if not self._remote_available():
4422
+ return
4423
+ command = " ".join(self.git.push_command())
4424
+ branch = self.git.branch_name() or "HEAD"
4425
+ upstream = self.git.upstream_branch()
4426
+ if upstream is None:
4427
+ remote = next(iter(self.git.remote_names()), "the remote")
4428
+ prompt = f"Push {branch} to {remote} and set it as upstream?"
4429
+ else:
4430
+ prompt = f"Push {branch} to {upstream}?"
4431
+ self._show_confirm(
4432
+ prompt,
4433
+ [command, *self._submodule_mismatch_notes()],
4434
+ self._run_push,
4435
+ )
4436
+
4437
+ def _submodule_mismatch_notes(self) -> list[str]:
4438
+ """Warning rows for the pull/push confirmations, one per submodule
4439
+ that is on a branch of its own.
4440
+
4441
+ Why these two commands get them: both move the parent's history past
4442
+ a gitlink that points into a submodule nobody is on the branch of, so
4443
+ what comes back from the remote — or what the remote is now told the
4444
+ submodule should be at — is a commit the checked-out submodule branch
4445
+ may not contain. The status view already says so in its warning
4446
+ label, but the confirmation is the last thing between the keypress
4447
+ and the network, and it is the one place the user is reading.
4448
+
4449
+ Read from the `_submodule_mismatches` cache the status worker fills,
4450
+ like `_update_submodule_warnings` and for the same reason: the check
4451
+ is a `git rev-parse` per submodule, and this runs on the UI thread
4452
+ while a dialog is being built.
4453
+ """
4454
+ if not self._submodule_mismatches:
4455
+ return []
4456
+ notes = [""]
4457
+ notes += [
4458
+ f"-- Warning: {_submodule_mismatch_text(sub_path, sub_branch, parent_branch)} --"
4459
+ for sub_path, sub_branch, parent_branch in self._submodule_mismatches
4460
+ ]
4461
+ return notes
4462
+
4463
+ def _remote_available(self) -> bool:
4464
+ """False (with the reason notified) when a network command can't run."""
4465
+ if self._remote_running:
4466
+ self.notify("A pull or push is already running")
4467
+ return False
4468
+ if not self.git.remote_names():
4469
+ self.notify("No remote is configured for this repository")
4470
+ return False
4471
+ return True
4472
+
4473
+ def _run_pull(self) -> None:
4474
+ self._start_remote_command(label="Pull", run=self.git.pull)
4475
+
4476
+ def _run_push(self) -> None:
4477
+ self._start_remote_command(label="Push", run=self.git.push)
4478
+
4479
+ def _start_remote_command(
4480
+ self,
4481
+ *,
4482
+ label: str,
4483
+ run: Callable[[], subprocess.CompletedProcess[str]],
4484
+ ) -> None:
4485
+ """Run a network git command on a thread worker.
4486
+
4487
+ The other writes (`git add`, `git rm`) run inline because they return
4488
+ in milliseconds. A pull or push sits on the network instead, and
4489
+ inline it would freeze every key in the TUI until it came back — the
4490
+ same reason the reads moved onto workers.
4491
+ """
4492
+ self._remote_running = True
4493
+ self.notify(f"{label} in progress…")
4494
+ self._remote_worker(label=label, run=run)
4495
+
4496
+ @work(thread=True, group="git-remote")
4497
+ def _remote_worker(
4498
+ self,
4499
+ *,
4500
+ label: str,
4501
+ run: Callable[[], subprocess.CompletedProcess[str]],
4502
+ ) -> None:
4503
+ try:
4504
+ result = run()
4505
+ except OSError as exc:
4506
+ result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr=str(exc))
4507
+ self.call_from_thread(self._apply_remote_result, label=label, result=result)
4508
+
4509
+ def _apply_remote_result(self, *, label: str, result: subprocess.CompletedProcess[str]) -> None:
4510
+ """Report a finished pull/push and reload both views from disk."""
4511
+ self._remote_running = False
4512
+ failed = getattr(result, "returncode", 0) != 0
4513
+ summary = _remote_result_summary(result, failed=failed)
4514
+ if failed:
4515
+ self.notify(f"{label} failed: {summary}" if summary else f"{label} failed")
4516
+ else:
4517
+ self.notify(f"{label}: {summary}" if summary else f"{label} finished")
4518
+ self._load_status()
4519
+ self._load_commits()
4520
+
4521
+ def action_settings(self) -> None:
4522
+ self.action_close_menus()
4523
+ settings = self._load_settings()
4524
+ self.push_screen(
4525
+ SettingsDialog(
4526
+ branch_prefix=bool(settings.get("branch_prefix_in_commits", False)),
4527
+ draft_command=_setting_str(
4528
+ settings, "commit_draft_command", COMMIT_DRAFT_COMMAND_DEFAULT
4529
+ ),
4530
+ draft_prompt=_setting_str(
4531
+ settings, "commit_draft_prompt", COMMIT_DRAFT_PROMPT_DEFAULT
4532
+ ),
4533
+ default_branch=_setting_str(settings, "default_branch", DEFAULT_BRANCH_DEFAULT),
4534
+ parse_suggestions=bool(settings.get("commit_draft_parse_suggestions", False)),
4535
+ editor=_setting_str(settings, "commit_editor", COMMIT_EDITOR_BUILTIN),
4536
+ editor_command=_setting_str(settings, "commit_editor_command", ""),
4537
+ ),
4538
+ callback=self._settings_result,
4539
+ )
4540
+
4541
+ def _settings_result(self, values: SettingsValues | None) -> None:
4542
+ """Dialog callback: values on save, None when the user pressed Esc."""
4543
+ self._focus_active_view()
4544
+ if values is None:
4545
+ return
4546
+ settings = self._load_settings()
4547
+ settings["branch_prefix_in_commits"] = values.branch_prefix
4548
+ settings["commit_draft_command"] = values.draft_command
4549
+ settings["commit_draft_prompt"] = values.draft_prompt
4550
+ settings["default_branch"] = values.default_branch
4551
+ settings["commit_draft_parse_suggestions"] = values.parse_suggestions
4552
+ settings["commit_editor"] = values.editor
4553
+ settings["commit_editor_command"] = values.editor_command
4554
+ if not self._save_settings(settings):
4555
+ self.notify("Settings saved (could not write to disk)")
4556
+ else:
4557
+ self.notify("Settings saved")
4558
+
4559
+ def action_open_submenu(self, name: str) -> None:
4560
+ """Open the nested menu registered as *name* (Options → Theme, ...).
4561
+
4562
+ The submenu cascades out of the row that opens it: the parent menu
4563
+ stays on screen with its cursor on that row, and the child hangs off
4564
+ its right edge with its own first row on the same line. The parent
4565
+ dropdown was hidden by the OptionList handler that invoked this
4566
+ action, so it is put back before the child is placed against it.
4567
+ """
4568
+ submenu = SUBMENUS_BY_NAME.get(name)
4569
+ if submenu is None:
4570
+ return
4571
+ try:
4572
+ label = self.query_one(f"#{submenu.parent_id}", MenuLabel)
4573
+ parent = self.query_one(f"#{_dropdown_id(submenu.parent_id)}", DropdownMenu)
4574
+ dropdown = self.query_one(f"#{submenu.dropdown_id}", DropdownMenu)
4575
+ except WIDGET_LOOKUP_ERRORS:
4576
+ return
4577
+ for dd in self.query(DropdownMenu):
4578
+ if dd is not parent:
4579
+ dd.hide()
4580
+ region = label.region
4581
+ parent_x, parent_y = region.x, region.y + 1
4582
+ parent.show_at(parent_x, parent_y)
4583
+ parent.highlight_action(submenu.action)
4584
+ # Both menus have a top border above their first entry, so the two
4585
+ # cancel out: putting the child's top `row` lines below the parent's
4586
+ # lands the child's first entry on the parent's row.
4587
+ row = parent.index_of_action(submenu.action) or 0
4588
+ x = self._cascade_x(
4589
+ left=parent_x, parent_width=parent.menu_width, width=dropdown.menu_width
4590
+ )
4591
+ dropdown.show_at(x, parent_y + row)
4592
+
4593
+ def _cascade_x(self, *, left: int, parent_width: int, width: int) -> int:
4594
+ """Where a cascading submenu starts.
4595
+
4596
+ Off the parent's right edge, or off its left one when that would run
4597
+ the submenu past the right edge of the screen.
4598
+ """
4599
+ x = left + parent_width
4600
+ try:
4601
+ screen_width = self.screen.size.width
4602
+ except WIDGET_LOOKUP_ERRORS:
4603
+ return x
4604
+ if screen_width and x + width > screen_width:
4605
+ return max(0, left - width)
4606
+ return x
4607
+
4608
+ def action_set_theme(self, theme_name: str) -> None:
4609
+ self.action_close_menus()
4610
+ try:
4611
+ self.theme = theme_name
4612
+ except InvalidThemeError as exc:
4613
+ self.notify(f"Could not set theme: {exc}")
4614
+ return
4615
+ self._restyle_menu_mnemonics()
4616
+ settings = self._load_settings()
4617
+ settings["theme"] = theme_name
4618
+ if not self._save_settings(settings):
4619
+ self.notify("Theme updated, but could not save settings")
4620
+ return
4621
+ self.notify(f"Theme: {theme_name}")
4622
+
4623
+ def _restyle_menu_mnemonics(self) -> None:
4624
+ """Re-render every menu label in the active theme's accelerator style.
4625
+
4626
+ Menu chrome is built once and stays mounted, so unlike the diff views
4627
+ it would otherwise keep the accelerator colors of the theme that was
4628
+ active when it was composed. A theme can also be restored before the
4629
+ DOM exists (`_load_saved_theme`), which is what the guard is for.
4630
+ """
4631
+ try:
4632
+ labels = list(self.query(MenuLabel))
4633
+ dropdowns = list(self.query(DropdownMenu))
4634
+ except WIDGET_LOOKUP_ERRORS:
4635
+ return
4636
+ for label in labels:
4637
+ label.apply_mnemonic_style()
4638
+ for dropdown in dropdowns:
4639
+ dropdown.apply_mnemonic_style()
4640
+
4641
+ def action_keys(self) -> None:
4642
+ """F1 / Help → Keys: show the keyboard shortcut reference."""
4643
+ self.action_close_menus()
4644
+ self.push_screen(HelpDialog(shortcuts=HELP_SHORTCUTS), callback=self._help_closed)
4645
+
4646
+ def _help_closed(self, _result: None) -> None:
4647
+ self._focus_active_view()
4648
+
4649
+ def action_screenshot(self, *args, **kwargs) -> None:
4650
+ """Override Textual's built-in screenshot action so screenshots
4651
+ cannot be triggered from this app."""
4652
+ self.notify("Screenshots are disabled")
4653
+
4654
+ def action_toggle_previous_screen(self) -> None:
4655
+ try:
4656
+ with self.suspend():
4657
+ self._screen.run_previous_screen_session()
4658
+ except (OSError, SuspendNotSupported):
4659
+ self.notify("Previous-screen toggle is not supported in this terminal")
4660
+
4661
+ def action_escape(self) -> None:
4662
+ """ESCAPE: close an open menu, else exit full-screen diff.
4663
+
4664
+ A nested menu closes back to the menu that opened it, so Escape
4665
+ walks out one level at a time instead of leaving the menus entirely.
4666
+
4667
+ Dialogs are not handled here — each is a ModalScreen that dismisses
4668
+ itself on Esc, and this binding cannot fire while one is open.
4669
+ """
4670
+ if self._close_submenu_to_parent():
4671
+ return
4672
+ any_open = False
4673
+ for dd in self.query(DropdownMenu):
4674
+ if dd.display:
4675
+ dd.hide()
4676
+ any_open = True
4677
+ for ml in self.query(MenuLabel):
4678
+ ml.remove_class("-active")
4679
+ if any_open:
4680
+ self._focus_active_view()
4681
+ return
4682
+ self._leave_diff_view()
4683
+ self._focus_active_view()
4684
+
4685
+ def action_close_menus(self) -> None:
4686
+ for dd in self.query(DropdownMenu):
4687
+ dd.hide()
4688
+ for ml in self.query(MenuLabel):
4689
+ ml.remove_class("-active")
4690
+ self._focus_active_view()
4691
+
4692
+ def _focus_active_view(self) -> None:
4693
+ current = self.query_one(ContentSwitcher).current
4694
+ if current == "status-view":
4695
+ self.query_one("#status_list", StatusList).focus()
4696
+ elif current == "history-view":
4697
+ self.query_one("#commit_list", CommitList).focus()
4698
+ elif current == "diff-view":
4699
+ self.query_one("#diff-full", FileDiff).focus()