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/__init__.py +3 -0
- gitnc/__main__.py +15 -0
- gitnc/app.py +4699 -0
- gitnc/git.py +1052 -0
- gitnc/terminal.py +230 -0
- gitnc/themes/__init__.py +25 -0
- gitnc/themes/atom-one-dark.yaml +100 -0
- gitnc/themes/atom-one-light.yaml +100 -0
- gitnc/themes/catppuccin-frappe.yaml +108 -0
- gitnc/themes/catppuccin-latte.yaml +101 -0
- gitnc/themes/catppuccin-macchiato.yaml +108 -0
- gitnc/themes/catppuccin-mocha.yaml +108 -0
- gitnc/themes/dracula.yaml +101 -0
- gitnc/themes/flexoki.yaml +103 -0
- gitnc/themes/gruvbox.yaml +102 -0
- gitnc/themes/midnight-commander.yaml +104 -0
- gitnc/themes/monokai.yaml +102 -0
- gitnc/themes/nord.yaml +106 -0
- gitnc/themes/rose-pine-dawn.yaml +107 -0
- gitnc/themes/rose-pine-moon.yaml +107 -0
- gitnc/themes/rose-pine.yaml +107 -0
- gitnc/themes/solarized-dark.yaml +104 -0
- gitnc/themes/solarized-light.yaml +104 -0
- gitnc/themes/textual-ansi.yaml +110 -0
- gitnc/themes/textual-dark.yaml +100 -0
- gitnc/themes/textual-light.yaml +101 -0
- gitnc/themes/tokyo-night.yaml +101 -0
- gitnc/ui_theme.py +268 -0
- gitnc-0.1.2.dist-info/METADATA +14 -0
- gitnc-0.1.2.dist-info/RECORD +33 -0
- gitnc-0.1.2.dist-info/WHEEL +4 -0
- gitnc-0.1.2.dist-info/entry_points.txt +2 -0
- gitnc-0.1.2.dist-info/licenses/LICENSE +232 -0
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()
|