1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from unittest.mock import MagicMock
import pytest
from pyratatui import Rect
from pygittools.tui.changes_list import ChangesList
from pygittools.tui.input_context import InputContext, command_context, input_context, nav_context
from pygittools.tui.keys import key, set_vim_enabled, vim_enabled
from pygittools.tui.log_list import CommitLogList
from pygittools.tui.pages.actions import ActionsPage
from pygittools.tui.pages.help import HelpPage
from pygittools.tui.tasks_list import TasksBoardList
from pygittools.tui.types import PageAction, PageContext
from pygittools.tui.worktrees_list import WorktreesList
_HeaderFocus = Literal["changes", "repo", "branch", "actions"]
_HomeTab = Literal["status", "log", "tasks", "worktrees"]
@dataclass
class FakeHost:
page_context: PageContext | None
changes: ChangesList
log: CommitLogList
tasks: TasksBoardList
worktrees: WorktreesList
header_focus: _HeaderFocus = "changes"
active_tab: _HomeTab = "status"
has_actions: bool = False
def set_header_focus(self, focus: _HeaderFocus) -> None:
self.header_focus = focus
def set_active_tab(self, tab: _HomeTab) -> None:
self.active_tab = tab
@pytest.fixture
def host(page_ctx: PageContext) -> FakeHost:
set_vim_enabled(False)
changes = ChangesList()
changes.refresh(page_ctx.repo)
log = CommitLogList()
log.refresh(page_ctx.repo)
tasks = TasksBoardList()
tasks.refresh(page_ctx.repo)
worktrees = WorktreesList()
worktrees.refresh(page_ctx.repo)
return FakeHost(page_context=page_ctx, changes=changes, log=log, tasks=tasks, worktrees=worktrees)
def test_nav_context_status_hints(host: FakeHost) -> None:
nav = nav_context()
host.header_focus = "repo"
assert "browse projects" in nav.status_hint(host)
host.header_focus = "branch"
assert "switch branch" in nav.status_hint(host)
host.active_tab = "log"
host.header_focus = "changes"
assert "arrows move" in nav.status_hint(host)
def test_nav_context_opens_command_mode(host: FakeHost) -> None:
dispatch = nav_context().dispatch_key(host, key("/"))
assert dispatch.next_mode == "command"
def _run_command(host: FakeHost, command: str):
cmd = command_context()
for char in command:
cmd.dispatch_key(host, key(char))
return cmd.dispatch_key(host, key("Enter"))
def test_command_opens_help(host: FakeHost) -> None:
dispatch = _run_command(host, "help")
assert dispatch.page_result is not None
assert dispatch.page_result.action == PageAction.PUSH
assert isinstance(dispatch.page_result.next_page, HelpPage)
assert dispatch.next_mode == "nav"
def test_command_opens_actions(host: FakeHost) -> None:
dispatch = _run_command(host, "actions")
assert dispatch.page_result is not None
assert dispatch.page_result.action == PageAction.PUSH
assert isinstance(dispatch.page_result.next_page, ActionsPage)
def test_command_quit(host: FakeHost) -> None:
dispatch = _run_command(host, "quit")
assert dispatch.page_result is not None
assert dispatch.page_result.action == PageAction.QUIT
def test_command_commit_enters_input(host: FakeHost) -> None:
dispatch = _run_command(host, "commit")
assert dispatch.next_mode == "input"
assert host.active_tab == "status"
assert host.header_focus == "changes"
def test_command_vim_toggle(host: FakeHost) -> None:
assert not vim_enabled()
assert _run_command(host, "vim").next_mode == "nav"
assert vim_enabled()
assert _run_command(host, "novim").next_mode == "nav"
assert not vim_enabled()
def test_command_switch_tab(host: FakeHost) -> None:
assert _run_command(host, "log").next_mode == "nav"
assert host.active_tab == "log"
def test_command_unknown(host: FakeHost) -> None:
cmd = command_context()
for char in "nope":
cmd.dispatch_key(host, key(char))
dispatch = cmd.dispatch_key(host, key("Enter"))
assert dispatch.next_mode is None
assert "Unknown command" in cmd.status_hint(host)
def test_command_esc_cancels(host: FakeHost) -> None:
cmd = command_context()
cmd.dispatch_key(host, key("h"))
dispatch = cmd.dispatch_key(host, key("Esc"))
assert dispatch.next_mode == "nav"
def test_nav_context_repo_header(host: FakeHost) -> None:
host.header_focus = "repo"
nav = nav_context()
nav.dispatch_key(host, key("Down"))
assert host.header_focus == "branch"
dispatch = nav.dispatch_key(host, key("Enter"))
assert dispatch.page_result is not None
assert dispatch.page_result.action == PageAction.PUSH
def test_nav_context_branch_header(host: FakeHost) -> None:
host.header_focus = "branch"
nav = nav_context()
nav.dispatch_key(host, key("Up"))
assert host.header_focus == "repo"
nav.dispatch_key(host, key("Down"))
assert host.header_focus == "branch"
nav.dispatch_key(host, key("Down"))
assert host.header_focus == "actions"
assert "No action groups" in nav.status_hint(host)
nav.dispatch_key(host, key("Down"))
assert host.header_focus == "changes"
host.header_focus = "branch"
dispatch = nav.dispatch_key(host, key("Enter"))
assert dispatch.page_result is not None
assert dispatch.page_result.action == PageAction.PUSH
def test_nav_context_tab_switch(host: FakeHost) -> None:
nav = nav_context()
host.active_tab = "log"
nav.dispatch_key(host, key("Left"))
assert host.active_tab == "status"
host.active_tab = "status"
nav.dispatch_key(host, key("Right"))
assert host.active_tab == "log"
def test_nav_context_actions_row(host: FakeHost) -> None:
host.has_actions = True
nav = nav_context()
nav.dispatch_key(host, key("Up"))
assert host.header_focus == "actions"
assert "open actions" in nav.status_hint(host)
nav.dispatch_key(host, key("Up"))
assert host.header_focus == "branch"
nav.dispatch_key(host, key("Down"))
assert host.header_focus == "actions"
dispatch = nav.dispatch_key(host, key("Enter"))
assert dispatch.page_result is not None
assert dispatch.page_result.action == PageAction.PUSH
assert isinstance(dispatch.page_result.next_page, ActionsPage)
host.header_focus = "actions"
nav.dispatch_key(host, key("Down"))
assert host.header_focus == "changes"
def test_nav_context_moves_to_branch_from_top(host: FakeHost) -> None:
host.changes._list_state.select(0)
nav = nav_context()
nav.dispatch_key(host, key("Up"))
assert host.header_focus == "actions"
assert nav.dispatch_key(host, key("Enter")).page_result is None
nav.dispatch_key(host, key("Up"))
assert host.header_focus == "branch"
def test_nav_context_log_tab(host: FakeHost) -> None:
host.active_tab = "log"
nav_context().dispatch_key(host, key("Down"))
assert (host.log._list_state.selected or 0) >= 0
def test_nav_context_enters_input_on_enter(host: FakeHost) -> None:
host.changes._list_state.select(0)
dispatch = nav_context().dispatch_key(host, key("Enter"))
assert dispatch.next_mode == "input"
def test_nav_context_vim_i_enters_input(host: FakeHost) -> None:
set_vim_enabled(True)
try:
host.changes._list_state.select(0)
dispatch = nav_context().dispatch_key(host, key("i"))
assert dispatch.next_mode == "input"
finally:
set_vim_enabled(False)
def test_nav_ignores_letter_keys_without_vim(host: FakeHost) -> None:
host.changes._list_state.select(0)
selected = host.changes._list_state.selected
dispatch = nav_context().dispatch_key(host, key("j"))
assert dispatch.next_mode is None
assert host.changes._list_state.selected == selected
assert host.changes.commit_message == ""
def test_input_context_esc_returns_nav(host: FakeHost) -> None:
dispatch = input_context().dispatch_key(host, key("Esc"))
assert dispatch.next_mode == "nav"
def test_input_context_commit_relinquishes(host: FakeHost, monkeypatch: pytest.MonkeyPatch) -> None:
host.changes.set_commit_message("msg")
host.changes.begin_input()
host.changes.refresh(host.page_context.repo) # type: ignore[union-attr]
monkeypatch.setattr(
"pygittools.tui.changes_list.commit_staged",
lambda _repo, _msg, *, amend=False: (True, ""),
)
input_context().dispatch_key(host, key("Tab"))
dispatch = input_context().dispatch_key(host, key("Enter"))
assert dispatch.next_mode == "nav"
def test_input_context_status_hint(host: FakeHost) -> None:
host.changes.begin_input()
assert "Esc" in InputContext().status_hint(host)
def test_nav_context_without_page_context() -> None:
host = FakeHost(
page_context=None,
changes=ChangesList(),
log=CommitLogList(),
tasks=TasksBoardList(),
worktrees=WorktreesList(),
)
assert nav_context().dispatch_key(host, key("Down")).page_result is None
def test_draw_helpers_unused() -> None:
# keep import of Rect available for call sites in other modules
assert Rect(0, 0, 1, 1).width == 1
assert MagicMock() is not None