pcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/edit_ui.py
ADDED
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
"""Temporary alternate-screen review of the session's git diff.
|
|
2
|
+
|
|
3
|
+
Every file's diff is one continuous scroll, with a small file index below it.
|
|
4
|
+
The diff pane keeps a cursor so a row can be acted on: each row knows the file
|
|
5
|
+
and, where it can, the line it stands for. That is what notes (review comments
|
|
6
|
+
that go back to the agent as a prompt) and opening the editor anchor to.
|
|
7
|
+
|
|
8
|
+
delta lays each hunk out separately, through one delta run for the whole
|
|
9
|
+
review, so a hunk's rows are always known. In the unified layout delta prints
|
|
10
|
+
one line per patch line, so rows map to lines exactly; side by side pairs lines
|
|
11
|
+
up, so its rows anchor to the hunk's first line instead.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shlex
|
|
18
|
+
import subprocess
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from contextlib import AbstractContextManager, nullcontext
|
|
21
|
+
from dataclasses import dataclass, replace
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from prompt_toolkit.application import Application, run_in_terminal
|
|
25
|
+
from prompt_toolkit.document import Document
|
|
26
|
+
from prompt_toolkit.filters import Always, Condition, has_focus
|
|
27
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
28
|
+
from prompt_toolkit.layout import ConditionalContainer, HSplit, Layout
|
|
29
|
+
from prompt_toolkit.widgets import Label, TextArea
|
|
30
|
+
|
|
31
|
+
from pcode.delta import Delta
|
|
32
|
+
from pcode.edit_transcript import DiffLexer, text_fragments
|
|
33
|
+
from pcode.edits import edit_text, patch_text
|
|
34
|
+
from pcode.frame import Frame
|
|
35
|
+
from pcode.git_diff import Review
|
|
36
|
+
from pcode.popup_ui import (
|
|
37
|
+
bind_list_paging,
|
|
38
|
+
fuzzy_match,
|
|
39
|
+
list_pane_height,
|
|
40
|
+
popup_container,
|
|
41
|
+
popup_mouse,
|
|
42
|
+
popup_style,
|
|
43
|
+
steer_list_from_query,
|
|
44
|
+
)
|
|
45
|
+
from pcode.prefix_keys import PrefixKeys
|
|
46
|
+
from pcode.runtime import EditCompleted
|
|
47
|
+
|
|
48
|
+
NO_MATCH = "No matching files."
|
|
49
|
+
PROMPTS = {"paths": "Search paths: ", "diffs": "Search diff lines: "}
|
|
50
|
+
VIEWS = ("all", "uncommitted", "review")
|
|
51
|
+
VIEW_NAMES = {"all": "All changes", "uncommitted": "Uncommitted", "review": "Since review"}
|
|
52
|
+
HEADING_STYLE = "bold underline"
|
|
53
|
+
NOTE_STYLE = "italic fg:ansiyellow"
|
|
54
|
+
# A hunk quoted in a note keeps this many lines either side of the noted one.
|
|
55
|
+
QUOTE_CONTEXT = 2
|
|
56
|
+
QUOTE_HUNK_LINES = 12
|
|
57
|
+
HUNK = re.compile(r"@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def change_title(change: EditCompleted) -> str:
|
|
61
|
+
return (
|
|
62
|
+
f"{change.operation:9} {edit_text(change.path)} · +{change.added} −{change.removed}"
|
|
63
|
+
).replace("\n", " ↵ ")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def change_heading(change: EditCompleted) -> str:
|
|
67
|
+
return (
|
|
68
|
+
f"{change.operation.capitalize()} {edit_text(change.path)}"
|
|
69
|
+
f" · +{change.added} −{change.removed}"
|
|
70
|
+
).replace("\n", " ↵ ")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def file_path(change: EditCompleted) -> str:
|
|
74
|
+
"""The path a change leaves behind: a rename's new name."""
|
|
75
|
+
return change.path.rsplit(" → ", 1)[-1]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Hunk:
|
|
80
|
+
header: str
|
|
81
|
+
lines: tuple[str, ...]
|
|
82
|
+
# The new-file line each body line stands for. A removed line takes the
|
|
83
|
+
# line that now sits where it was.
|
|
84
|
+
numbers: tuple[int, ...]
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def start(self) -> int:
|
|
88
|
+
return self.numbers[0] if self.numbers else 1
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def split_patch(patch: str) -> tuple[list[str], list[str], list[Hunk]]:
|
|
92
|
+
"""A patch's `---`/`+++` header, its other preamble lines, and its hunks."""
|
|
93
|
+
header, preamble, hunks = [], [], []
|
|
94
|
+
current: list | None = None
|
|
95
|
+
for line in patch.splitlines():
|
|
96
|
+
if line.startswith("@@"):
|
|
97
|
+
match = HUNK.match(line)
|
|
98
|
+
current = [line, [], int(match[1]) if match else 1]
|
|
99
|
+
hunks.append(current)
|
|
100
|
+
elif current is not None:
|
|
101
|
+
current[1].append(line)
|
|
102
|
+
elif line.startswith(("--- ", "+++ ")):
|
|
103
|
+
header.append(line)
|
|
104
|
+
else:
|
|
105
|
+
preamble.append(line)
|
|
106
|
+
parsed = []
|
|
107
|
+
for line, body, start in hunks:
|
|
108
|
+
numbers, new = [], max(1, start)
|
|
109
|
+
for text in body:
|
|
110
|
+
if text.startswith("+") or text.startswith(" ") or not text:
|
|
111
|
+
numbers.append(new)
|
|
112
|
+
new += 1
|
|
113
|
+
elif text.startswith("\\") and numbers:
|
|
114
|
+
numbers.append(numbers[-1])
|
|
115
|
+
else:
|
|
116
|
+
numbers.append(new)
|
|
117
|
+
parsed.append(Hunk(line, tuple(body), tuple(numbers)))
|
|
118
|
+
return header, preamble, parsed
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def hunk_patch(header: list[str], hunk: Hunk) -> str:
|
|
122
|
+
"""One hunk as a patch of its own, for delta: the header names the language."""
|
|
123
|
+
return "\n".join([*header, hunk.header, *hunk.lines])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class Anchor:
|
|
128
|
+
"""Where a note attaches: a file, and within it a hunk and line where known."""
|
|
129
|
+
|
|
130
|
+
path: str
|
|
131
|
+
hunk: str = ""
|
|
132
|
+
# Index of the noted line in the hunk's body; None notes the whole hunk.
|
|
133
|
+
index: int | None = None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class Note:
|
|
138
|
+
anchor: Anchor
|
|
139
|
+
line: int | None
|
|
140
|
+
quote: tuple[str, ...]
|
|
141
|
+
text: str
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class Row:
|
|
146
|
+
change: EditCompleted | None = None
|
|
147
|
+
line: int | None = None
|
|
148
|
+
anchor: Anchor | None = None
|
|
149
|
+
note: Note | None = None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class Page:
|
|
154
|
+
"""The diff pane's document: text, delta's styled rows, and what each row is."""
|
|
155
|
+
|
|
156
|
+
text: str
|
|
157
|
+
styled: dict[int, list[tuple[str, str]]]
|
|
158
|
+
rows: list[Row]
|
|
159
|
+
# First row of each change, in order.
|
|
160
|
+
starts: list[int]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def lay_out(
|
|
164
|
+
changes: list[EditCompleted],
|
|
165
|
+
rendered: dict[str, list | None],
|
|
166
|
+
notes: list[Note],
|
|
167
|
+
*,
|
|
168
|
+
side_by_side: bool = False,
|
|
169
|
+
uncommitted: frozenset[str] = frozenset(),
|
|
170
|
+
empty: str = "",
|
|
171
|
+
dedent: bool = True,
|
|
172
|
+
) -> Page:
|
|
173
|
+
"""Every change in one document, notes under the rows they are about.
|
|
174
|
+
|
|
175
|
+
`rendered` holds delta's line groups by hunk patch; a hunk missing from it,
|
|
176
|
+
or delta failed on, shows its own patch lines. `dedent` strips each hunk's
|
|
177
|
+
shared indentation (`diff_dedent`), as `render_review` must have too.
|
|
178
|
+
"""
|
|
179
|
+
lines: list[str] = []
|
|
180
|
+
styled: dict[int, list[tuple[str, str]]] = {}
|
|
181
|
+
rows: list[Row] = []
|
|
182
|
+
starts: list[int] = []
|
|
183
|
+
# Where each note of the file being laid out goes, worked out per file.
|
|
184
|
+
targets: dict[int, Anchor] = {}
|
|
185
|
+
|
|
186
|
+
def add(text: str, row: Row, style: list[tuple[str, str]] | None = None) -> None:
|
|
187
|
+
if style is not None:
|
|
188
|
+
styled[len(lines)] = style
|
|
189
|
+
lines.append(text)
|
|
190
|
+
rows.append(row)
|
|
191
|
+
|
|
192
|
+
def add_notes(anchor: Anchor) -> None:
|
|
193
|
+
for number, note in enumerate(notes):
|
|
194
|
+
if targets.get(number) == anchor:
|
|
195
|
+
text = f" ✎ {note.text}"
|
|
196
|
+
add(text, Row(rows[-1].change, note.line, anchor, note), [(NOTE_STYLE, text)])
|
|
197
|
+
|
|
198
|
+
for change in changes:
|
|
199
|
+
path = file_path(change)
|
|
200
|
+
if lines:
|
|
201
|
+
add("", Row())
|
|
202
|
+
starts.append(len(lines))
|
|
203
|
+
marker = "● " if change.path in uncommitted else ""
|
|
204
|
+
heading = marker + change_heading(change)
|
|
205
|
+
file_anchor = Anchor(path)
|
|
206
|
+
header, preamble, hunks = (
|
|
207
|
+
split_patch(patch_text(change.patch, dedent=dedent)) if change.patch else ([], [], [])
|
|
208
|
+
)
|
|
209
|
+
# A note goes under its line where that row is shown, else under its
|
|
210
|
+
# hunk, else (another view, or a refresh changed the hunk) under the
|
|
211
|
+
# file's heading. Its own anchor is kept for the views that have it.
|
|
212
|
+
shown = {file_anchor}
|
|
213
|
+
for hunk in hunks:
|
|
214
|
+
shown.add(Anchor(path, hunk.header))
|
|
215
|
+
groups = rendered.get(hunk_patch(header, hunk), None)
|
|
216
|
+
if groups is None or (not side_by_side and len(groups) == len(hunk.lines)):
|
|
217
|
+
shown.update(Anchor(path, hunk.header, i) for i in range(len(hunk.lines)))
|
|
218
|
+
targets = {
|
|
219
|
+
number: next(
|
|
220
|
+
a for a in (note.anchor, Anchor(path, note.anchor.hunk), file_anchor) if a in shown
|
|
221
|
+
)
|
|
222
|
+
for number, note in enumerate(notes)
|
|
223
|
+
if note.anchor.path == path
|
|
224
|
+
}
|
|
225
|
+
add(heading, Row(change, None, file_anchor), [(HEADING_STYLE, heading)])
|
|
226
|
+
add_notes(file_anchor)
|
|
227
|
+
for text in preamble:
|
|
228
|
+
add(text, Row(change, None, file_anchor))
|
|
229
|
+
for number, hunk in enumerate(hunks):
|
|
230
|
+
groups = rendered.get(hunk_patch(header, hunk))
|
|
231
|
+
exact = groups is not None and not side_by_side and len(groups) == len(hunk.lines)
|
|
232
|
+
hunk_anchor = Anchor(path, hunk.header)
|
|
233
|
+
if groups is None:
|
|
234
|
+
add(hunk.header, Row(change, hunk.start, hunk_anchor))
|
|
235
|
+
add_notes(hunk_anchor)
|
|
236
|
+
for index, text in enumerate(hunk.lines):
|
|
237
|
+
anchor = Anchor(path, hunk.header, index)
|
|
238
|
+
add(text, Row(change, hunk.numbers[index], anchor))
|
|
239
|
+
add_notes(anchor)
|
|
240
|
+
continue
|
|
241
|
+
if number:
|
|
242
|
+
# delta draws no hunk header here, so mark where one hunk ends.
|
|
243
|
+
add("⋯", Row(change, hunk.start, hunk_anchor), [("fg:ansibrightblack", "⋯")])
|
|
244
|
+
for index, group in enumerate(groups):
|
|
245
|
+
anchor = Anchor(path, hunk.header, index) if exact else hunk_anchor
|
|
246
|
+
line = hunk.numbers[index] if exact else hunk.start
|
|
247
|
+
for row in group:
|
|
248
|
+
fragments = text_fragments(row)
|
|
249
|
+
add(
|
|
250
|
+
"".join(text for _, text in fragments), Row(change, line, anchor), fragments
|
|
251
|
+
)
|
|
252
|
+
if exact:
|
|
253
|
+
add_notes(anchor)
|
|
254
|
+
add_notes(hunk_anchor)
|
|
255
|
+
if change.truncated:
|
|
256
|
+
add("… additional diff rows omitted", Row(change, None, file_anchor))
|
|
257
|
+
if change.omitted:
|
|
258
|
+
add(f"Diff unavailable: {edit_text(change.omitted)}", Row(change, None, file_anchor))
|
|
259
|
+
if not lines:
|
|
260
|
+
add(empty, Row())
|
|
261
|
+
return Page("\n".join(lines), styled, rows, starts)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def quote_for(change: EditCompleted, anchor: Anchor) -> tuple[str, ...]:
|
|
265
|
+
"""The diff lines a note quotes: around the noted line, else the hunk's start."""
|
|
266
|
+
if not anchor.hunk:
|
|
267
|
+
return ()
|
|
268
|
+
# Undedented: the agent reads the code as it is. Dedenting keeps every
|
|
269
|
+
# line, so the anchor's index points at the same line either way.
|
|
270
|
+
_, _, hunks = split_patch(edit_text(change.patch))
|
|
271
|
+
hunk = next((h for h in hunks if h.header == anchor.hunk), None)
|
|
272
|
+
if hunk is None:
|
|
273
|
+
return ()
|
|
274
|
+
if anchor.index is None:
|
|
275
|
+
body = hunk.lines[:QUOTE_HUNK_LINES]
|
|
276
|
+
return (*body, "…") if len(hunk.lines) > QUOTE_HUNK_LINES else body
|
|
277
|
+
low = max(0, anchor.index - QUOTE_CONTEXT)
|
|
278
|
+
return hunk.lines[low : anchor.index + QUOTE_CONTEXT + 1]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def notes_prompt(notes: list[Note]) -> str:
|
|
282
|
+
"""Notes as a message to the agent: where, what the diff said there, and the note."""
|
|
283
|
+
if not notes:
|
|
284
|
+
return ""
|
|
285
|
+
parts = ["Review notes on the diff:"]
|
|
286
|
+
for note in notes:
|
|
287
|
+
where = note.anchor.path + (f":{note.line}" if note.line else "")
|
|
288
|
+
block = [where]
|
|
289
|
+
if note.quote:
|
|
290
|
+
# A fence longer than any backtick run the quoted code holds.
|
|
291
|
+
longest = max((len(run) for run in re.findall(r"`+", "\n".join(note.quote))), default=0)
|
|
292
|
+
fence = "`" * max(3, longest + 1)
|
|
293
|
+
block += [f"{fence}diff", *note.quote, fence]
|
|
294
|
+
block.append(note.text)
|
|
295
|
+
parts.append("\n".join(block))
|
|
296
|
+
return "\n\n".join(parts)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def editor_command(path: Path, line: int | None, environ=os.environ) -> list[str]:
|
|
300
|
+
"""$VISUAL or $EDITOR opening `path`, at `line` where the editor can be told it."""
|
|
301
|
+
args = shlex.split(environ.get("VISUAL") or environ.get("EDITOR") or "vi")
|
|
302
|
+
name = Path(args[0]).name
|
|
303
|
+
if not line:
|
|
304
|
+
return [*args, str(path)]
|
|
305
|
+
if name in {"code", "code-insiders", "codium", "cursor", "windsurf"}:
|
|
306
|
+
return [*args, "--goto", f"{path}:{line}"]
|
|
307
|
+
if name in {"subl", "zed", "hx", "helix"}:
|
|
308
|
+
return [*args, f"{path}:{line}"]
|
|
309
|
+
return [*args, f"+{line}", str(path)]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def render_review(
|
|
313
|
+
review: Review,
|
|
314
|
+
delta: Delta | None,
|
|
315
|
+
width: int,
|
|
316
|
+
known: dict[str, list | None] | None = None,
|
|
317
|
+
*,
|
|
318
|
+
dedent: bool = True,
|
|
319
|
+
) -> dict[str, list | None]:
|
|
320
|
+
"""delta's layout of every hunk in every view of `review`, in one delta run.
|
|
321
|
+
|
|
322
|
+
`known` holds hunks already rendered at `width`. Safe off the event loop.
|
|
323
|
+
"""
|
|
324
|
+
if delta is None:
|
|
325
|
+
return {}
|
|
326
|
+
known = known or {}
|
|
327
|
+
patches = []
|
|
328
|
+
for change in (*review.changes, *review.uncommitted, *(review.since_review or [])):
|
|
329
|
+
if change.patch:
|
|
330
|
+
header, _, hunks = split_patch(patch_text(change.patch, dedent=dedent))
|
|
331
|
+
patches += [hunk_patch(header, hunk) for hunk in hunks]
|
|
332
|
+
patches = list(dict.fromkeys(patches))
|
|
333
|
+
missing = [patch for patch in patches if patch not in known]
|
|
334
|
+
found = dict(zip(missing, delta.render_all(missing, width), strict=True)) if missing else {}
|
|
335
|
+
return {patch: known[patch] if patch in known else found[patch] for patch in patches}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def matching_rows(text: str, terms: list[str]) -> list[int]:
|
|
339
|
+
"""Rows where every query word fuzzy-matches the line, in document order."""
|
|
340
|
+
if not terms:
|
|
341
|
+
return []
|
|
342
|
+
return [
|
|
343
|
+
row
|
|
344
|
+
for row, line in enumerate(text.casefold().splitlines())
|
|
345
|
+
if all(fuzzy_match(term, line) for term in terms)
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class DiffBrowser:
|
|
350
|
+
"""The diff fills the screen; the file index stays a small pane below it.
|
|
351
|
+
|
|
352
|
+
`review` is shown at once; `reload` loads it again (the `g` shortcut, and
|
|
353
|
+
after the editor closes), off the event loop. `mark` records the review's
|
|
354
|
+
checkpoint. Notes survive reloads and are read from `notes` once the popup
|
|
355
|
+
closes.
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
def __init__(
|
|
359
|
+
self,
|
|
360
|
+
review: Review,
|
|
361
|
+
*,
|
|
362
|
+
reload: Callable[[], Review] | None = None,
|
|
363
|
+
mark: Callable[[str], None] | None = None,
|
|
364
|
+
code_theme: str = "monokai",
|
|
365
|
+
delta: Delta | None = None,
|
|
366
|
+
rendered: dict[str, list | None] | None = None,
|
|
367
|
+
width: int = 0,
|
|
368
|
+
dedent: bool = True,
|
|
369
|
+
key_prefix: str | None = None,
|
|
370
|
+
handoff: Callable[[], AbstractContextManager] = nullcontext,
|
|
371
|
+
**app_options,
|
|
372
|
+
) -> None:
|
|
373
|
+
self.review = review
|
|
374
|
+
# Wraps $EDITOR: whatever the terminal must stop sending while it runs.
|
|
375
|
+
self.handoff = handoff
|
|
376
|
+
self.reload = reload
|
|
377
|
+
self.mark = mark
|
|
378
|
+
self.delta = delta
|
|
379
|
+
self.dedent = dedent
|
|
380
|
+
# `rendered` is delta's layout of the review's hunks at `width`, made
|
|
381
|
+
# before the popup opened so it never flashes the plain patch first.
|
|
382
|
+
self.width = width
|
|
383
|
+
self.rendered: dict[str, list | None] = dict(rendered or {})
|
|
384
|
+
self.rendering = False
|
|
385
|
+
self.loads = 0
|
|
386
|
+
self.notes: list[Note] = []
|
|
387
|
+
self.notice = ""
|
|
388
|
+
self.view = "all"
|
|
389
|
+
if review.since_review:
|
|
390
|
+
self.view = "review"
|
|
391
|
+
elif review.since_review == []:
|
|
392
|
+
self.notice = "Nothing new since your last review"
|
|
393
|
+
self.scope = "paths"
|
|
394
|
+
self.visible: list[EditCompleted] = []
|
|
395
|
+
self.page = Page("", {}, [], [])
|
|
396
|
+
self.editing: Note | Row | None = None
|
|
397
|
+
self._syncing = False
|
|
398
|
+
self.query = TextArea(height=1, prompt=lambda: PROMPTS[self.scope], multiline=False)
|
|
399
|
+
self.note = TextArea(height=1, prompt=self.note_prompt, multiline=False)
|
|
400
|
+
self.files = TextArea(read_only=True, wrap_lines=False, scrollbar=True)
|
|
401
|
+
self.files.window.cursorline = Always()
|
|
402
|
+
self.lexer = DiffLexer(code_theme)
|
|
403
|
+
self.diff = TextArea(read_only=True, wrap_lines=False, scrollbar=True, lexer=self.lexer)
|
|
404
|
+
self.diff.window.cursorline = Always()
|
|
405
|
+
self.query.buffer.on_text_changed += lambda _: self.refresh(first_match=True)
|
|
406
|
+
self.files.buffer.on_cursor_position_changed += lambda _: self.follow_files()
|
|
407
|
+
self.diff.buffer.on_cursor_position_changed += lambda _: self.follow_diff()
|
|
408
|
+
keys = KeyBindings()
|
|
409
|
+
noting = has_focus(self.note)
|
|
410
|
+
|
|
411
|
+
@keys.add("escape", eager=True, filter=~noting)
|
|
412
|
+
@keys.add("c-c", filter=~noting)
|
|
413
|
+
def close(event):
|
|
414
|
+
event.app.exit()
|
|
415
|
+
|
|
416
|
+
@keys.add("escape", eager=True, filter=noting)
|
|
417
|
+
@keys.add("c-c", filter=noting)
|
|
418
|
+
def cancel_note(event):
|
|
419
|
+
self.editing = None
|
|
420
|
+
event.app.layout.focus(self.diff)
|
|
421
|
+
|
|
422
|
+
@keys.add("enter", filter=noting)
|
|
423
|
+
def save_note(event):
|
|
424
|
+
self.save_note(self.note.text)
|
|
425
|
+
event.app.layout.focus(self.diff)
|
|
426
|
+
|
|
427
|
+
# Tab only toggles the panes; the query line is entered with f and left with Enter.
|
|
428
|
+
@keys.add("tab", filter=~noting)
|
|
429
|
+
@keys.add("s-tab", filter=~noting)
|
|
430
|
+
def toggle(event):
|
|
431
|
+
focused = event.app.layout.has_focus(self.diff)
|
|
432
|
+
event.app.layout.focus(self.files if focused else self.diff)
|
|
433
|
+
|
|
434
|
+
steer_list_from_query(keys, self.query, self.files)
|
|
435
|
+
bind_list_paging(keys, self.files, has_focus(self.files) | has_focus(self.query))
|
|
436
|
+
bind_list_paging(keys, self.diff, has_focus(self.diff))
|
|
437
|
+
|
|
438
|
+
@keys.add("enter", filter=has_focus(self.query))
|
|
439
|
+
def search_done(event):
|
|
440
|
+
event.app.layout.focus(self.files if self.scope == "paths" else self.diff)
|
|
441
|
+
|
|
442
|
+
self.prefix_keys = shortcuts = PrefixKeys(key_prefix)
|
|
443
|
+
shortcuts.set_help(
|
|
444
|
+
lambda: [
|
|
445
|
+
("↑/↓", "Select file / move in the diff"),
|
|
446
|
+
("PgUp/PgDn", "Page"),
|
|
447
|
+
("Ctrl+U/D", "Half page"),
|
|
448
|
+
("Type", "Search in the search field"),
|
|
449
|
+
("Enter", "Leave the search field / save a note"),
|
|
450
|
+
("Tab/Shift+Tab", "Switch files / diff"),
|
|
451
|
+
("Esc/Ctrl+C", "Close (notes go to the prompt)"),
|
|
452
|
+
]
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
@shortcuts.add("f", "Search the focused pane")
|
|
456
|
+
def search(event):
|
|
457
|
+
if event.app.layout.has_focus(self.files):
|
|
458
|
+
self.search("paths")
|
|
459
|
+
elif event.app.layout.has_focus(self.diff):
|
|
460
|
+
self.search("diffs")
|
|
461
|
+
else:
|
|
462
|
+
self.search(self.scope)
|
|
463
|
+
|
|
464
|
+
# Emacs's incremental search keys: s forward, r in reverse.
|
|
465
|
+
@shortcuts.add("s", "Next match", group="Next / previous match")
|
|
466
|
+
def next_match(event):
|
|
467
|
+
self.jump(1)
|
|
468
|
+
|
|
469
|
+
@shortcuts.add("r", "Previous match", group="Next / previous match")
|
|
470
|
+
def previous_match(event):
|
|
471
|
+
self.jump(-1)
|
|
472
|
+
|
|
473
|
+
@shortcuts.add("v", lambda: f"View: {VIEW_NAMES[self.next_view()]}")
|
|
474
|
+
def next_view(event):
|
|
475
|
+
self.show(self.next_view())
|
|
476
|
+
|
|
477
|
+
@shortcuts.add("n", "Note on this line (sent to the prompt on close)")
|
|
478
|
+
def add_note(event):
|
|
479
|
+
self.start_note()
|
|
480
|
+
|
|
481
|
+
@shortcuts.add("a", "Mark reviewed", filter=Condition(lambda: self.mark is not None))
|
|
482
|
+
def mark_reviewed(event):
|
|
483
|
+
self.mark_reviewed()
|
|
484
|
+
|
|
485
|
+
@shortcuts.add("e", "Open in $EDITOR")
|
|
486
|
+
def open_editor(event):
|
|
487
|
+
self.open_editor()
|
|
488
|
+
|
|
489
|
+
@shortcuts.add("g", "Refresh", filter=Condition(lambda: self.reload is not None))
|
|
490
|
+
def refresh(event):
|
|
491
|
+
self.app.create_background_task(self.refetch())
|
|
492
|
+
|
|
493
|
+
root = HSplit(
|
|
494
|
+
[
|
|
495
|
+
Label(self.tabs),
|
|
496
|
+
Label(self.heading),
|
|
497
|
+
self.query,
|
|
498
|
+
Frame(self.diff, title="Diff"),
|
|
499
|
+
ConditionalContainer(self.note, filter=Condition(lambda: self.editing is not None)),
|
|
500
|
+
Frame(
|
|
501
|
+
self.files,
|
|
502
|
+
title="Files",
|
|
503
|
+
height=lambda: list_pane_height(len(self.changes())),
|
|
504
|
+
),
|
|
505
|
+
Label(shortcuts.summary),
|
|
506
|
+
]
|
|
507
|
+
)
|
|
508
|
+
self.app = Application(
|
|
509
|
+
# Open in the search line, so typing filters straight away.
|
|
510
|
+
layout=Layout(popup_container(root, shortcuts), focused_element=self.query),
|
|
511
|
+
key_bindings=shortcuts.key_bindings(keys),
|
|
512
|
+
full_screen=True,
|
|
513
|
+
mouse_support=popup_mouse(shortcuts),
|
|
514
|
+
style=popup_style(app_options.pop("style", None)),
|
|
515
|
+
**app_options,
|
|
516
|
+
)
|
|
517
|
+
self.app.before_render += self.rewidth
|
|
518
|
+
self.refresh()
|
|
519
|
+
|
|
520
|
+
# What is shown
|
|
521
|
+
|
|
522
|
+
def changes(self, view: str | None = None) -> list[EditCompleted]:
|
|
523
|
+
view = view or self.view
|
|
524
|
+
if view == "uncommitted":
|
|
525
|
+
return self.review.uncommitted
|
|
526
|
+
if view == "review":
|
|
527
|
+
return self.review.since_review or []
|
|
528
|
+
return self.review.changes
|
|
529
|
+
|
|
530
|
+
def views(self) -> list[str]:
|
|
531
|
+
"""The views this review offers: since review only once something was reviewed."""
|
|
532
|
+
return [v for v in VIEWS if v != "review" or self.review.since_review is not None]
|
|
533
|
+
|
|
534
|
+
def next_view(self) -> str:
|
|
535
|
+
views = self.views()
|
|
536
|
+
return views[(views.index(self.view) + 1) % len(views)] if self.view in views else "all"
|
|
537
|
+
|
|
538
|
+
def tabs(self) -> str:
|
|
539
|
+
return " ".join(
|
|
540
|
+
("▸ " if view == self.view else " ")
|
|
541
|
+
+ f"{VIEW_NAMES[view]} ({len(self.changes(view))})"
|
|
542
|
+
for view in self.views()
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
def heading(self) -> str:
|
|
546
|
+
if self.view == "uncommitted":
|
|
547
|
+
title = "what the next commit would take in, against HEAD"
|
|
548
|
+
elif self.view == "review":
|
|
549
|
+
title = "changed since you last marked the diff reviewed"
|
|
550
|
+
else:
|
|
551
|
+
title = self.review.title
|
|
552
|
+
position = self.position()
|
|
553
|
+
where = ""
|
|
554
|
+
if (row := self.current_row()) is not None and row.change is not None:
|
|
555
|
+
line = f":{row.line}" if row.line else ""
|
|
556
|
+
where = f" · {position}/{len(self.visible)} {file_path(row.change)}{line}"
|
|
557
|
+
notes = f" · {len(self.notes)} note{'s' * (len(self.notes) != 1)}" if self.notes else ""
|
|
558
|
+
notice = f" · {self.notice}" if self.notice else ""
|
|
559
|
+
return f"{title}{where}{notes}{notice}"
|
|
560
|
+
|
|
561
|
+
def empty(self) -> str:
|
|
562
|
+
if self.view == "uncommitted":
|
|
563
|
+
return "No uncommitted changes."
|
|
564
|
+
if self.view == "review":
|
|
565
|
+
return "Nothing new since your last review."
|
|
566
|
+
return self.review.empty
|
|
567
|
+
|
|
568
|
+
def show(self, view: str) -> None:
|
|
569
|
+
keep = self.current_row()
|
|
570
|
+
self.view = view
|
|
571
|
+
self.notice = ""
|
|
572
|
+
self.refresh(keep=keep)
|
|
573
|
+
|
|
574
|
+
# Laying out
|
|
575
|
+
|
|
576
|
+
def side_by_side(self) -> bool:
|
|
577
|
+
return self.delta is not None and self.delta.side_by_side(self.width)
|
|
578
|
+
|
|
579
|
+
def render(self, width: int, review: Review | None = None) -> dict[str, list | None]:
|
|
580
|
+
known = self.rendered if width == self.width else {}
|
|
581
|
+
return render_review(review or self.review, self.delta, width, known, dedent=self.dedent)
|
|
582
|
+
|
|
583
|
+
def pane_width(self) -> int:
|
|
584
|
+
# The frame's two borders, the scrollbar, and a spare column: every
|
|
585
|
+
# padded delta row is exactly the width it was rendered for.
|
|
586
|
+
return max(1, self.app.output.get_size().columns - 4)
|
|
587
|
+
|
|
588
|
+
def rewidth(self, _app=None) -> None:
|
|
589
|
+
"""delta lays a diff out for one width, so a resize renders it again."""
|
|
590
|
+
width = self.pane_width()
|
|
591
|
+
if self.delta is None or width == self.width or self.rendering:
|
|
592
|
+
return
|
|
593
|
+
self.rendering = True
|
|
594
|
+
self.app.create_background_task(self.rerender(width))
|
|
595
|
+
|
|
596
|
+
async def rerender(self, width: int) -> None:
|
|
597
|
+
review = self.review
|
|
598
|
+
try:
|
|
599
|
+
rendered = await asyncio.to_thread(self.render, width, review)
|
|
600
|
+
finally:
|
|
601
|
+
self.rendering = False
|
|
602
|
+
if self.review is not review:
|
|
603
|
+
# A reload landed meanwhile; the next render lays the new one out.
|
|
604
|
+
self.app.invalidate()
|
|
605
|
+
return
|
|
606
|
+
keep = self.current_row()
|
|
607
|
+
self.rendered, self.width = rendered, width
|
|
608
|
+
self.refresh(keep=keep)
|
|
609
|
+
self.app.invalidate()
|
|
610
|
+
|
|
611
|
+
async def refetch(self) -> None:
|
|
612
|
+
"""Load the review again, keeping the view, the place, and the notes."""
|
|
613
|
+
if self.reload is None:
|
|
614
|
+
return
|
|
615
|
+
self.notice = "Refreshing…"
|
|
616
|
+
self.app.invalidate()
|
|
617
|
+
width = self.width or self.pane_width()
|
|
618
|
+
# Only the newest reload is applied, whichever finishes last.
|
|
619
|
+
self.loads += 1
|
|
620
|
+
generation = self.loads
|
|
621
|
+
|
|
622
|
+
def load():
|
|
623
|
+
review = self.reload()
|
|
624
|
+
return review, self.render(width, review)
|
|
625
|
+
|
|
626
|
+
try:
|
|
627
|
+
review, rendered = await asyncio.to_thread(load)
|
|
628
|
+
except Exception as error: # a failed reload must not take the popup down
|
|
629
|
+
if generation == self.loads:
|
|
630
|
+
self.notice = f"Refresh failed: {error}"
|
|
631
|
+
self.app.invalidate()
|
|
632
|
+
return
|
|
633
|
+
if generation != self.loads:
|
|
634
|
+
return
|
|
635
|
+
keep = self.current_row()
|
|
636
|
+
self.review, self.rendered, self.width = review, rendered, width
|
|
637
|
+
if self.view not in self.views():
|
|
638
|
+
self.view = "all"
|
|
639
|
+
self.notice = "Refreshed"
|
|
640
|
+
self.refresh(keep=keep)
|
|
641
|
+
self.app.invalidate()
|
|
642
|
+
|
|
643
|
+
def refresh(self, keep: Row | None = None, *, first_match: bool = False) -> None:
|
|
644
|
+
"""Lay the visible changes out again, staying on `keep`'s file and line.
|
|
645
|
+
|
|
646
|
+
`first_match` moves to the first row a diff search matches instead.
|
|
647
|
+
"""
|
|
648
|
+
keep = keep if keep is not None else self.current_row()
|
|
649
|
+
self.visible = [change for change in self.changes() if self.matches(change)]
|
|
650
|
+
# In the uncommitted view every file is uncommitted, so none is marked.
|
|
651
|
+
uncommitted = frozenset(
|
|
652
|
+
() if self.view == "uncommitted" else (c.path for c in self.review.uncommitted)
|
|
653
|
+
)
|
|
654
|
+
self.page = lay_out(
|
|
655
|
+
self.visible,
|
|
656
|
+
self.rendered,
|
|
657
|
+
self.notes,
|
|
658
|
+
side_by_side=self.side_by_side(),
|
|
659
|
+
uncommitted=uncommitted,
|
|
660
|
+
empty=NO_MATCH if self.changes() and not self.visible else self.empty(),
|
|
661
|
+
dedent=self.dedent,
|
|
662
|
+
)
|
|
663
|
+
self.lexer.rows = self.page.styled
|
|
664
|
+
row = self.find(keep)
|
|
665
|
+
self._syncing = True
|
|
666
|
+
self.diff.buffer.set_document(
|
|
667
|
+
Document(self.page.text, self.row_index(row)), bypass_readonly=True
|
|
668
|
+
)
|
|
669
|
+
listing = [
|
|
670
|
+
("● " if change.path in uncommitted else " ") + change_title(change)
|
|
671
|
+
for change in self.visible
|
|
672
|
+
]
|
|
673
|
+
text = "\n".join(listing) or self.page.text
|
|
674
|
+
self.files.buffer.set_document(Document(text, 0), bypass_readonly=True)
|
|
675
|
+
self._syncing = False
|
|
676
|
+
if (first_match or keep is None) and (rows := self.diff_rows()):
|
|
677
|
+
self.go_to(rows[0])
|
|
678
|
+
self.follow_diff()
|
|
679
|
+
|
|
680
|
+
def find(self, keep: Row | None) -> int:
|
|
681
|
+
"""The row showing `keep`'s file and line, else its file, else the top."""
|
|
682
|
+
if keep is None or keep.change is None:
|
|
683
|
+
return 0
|
|
684
|
+
path = file_path(keep.change)
|
|
685
|
+
fallback = None
|
|
686
|
+
for number, row in enumerate(self.page.rows):
|
|
687
|
+
if row.change is None or file_path(row.change) != path:
|
|
688
|
+
continue
|
|
689
|
+
if fallback is None:
|
|
690
|
+
fallback = number
|
|
691
|
+
if keep.note is not None and row.note is keep.note:
|
|
692
|
+
return number
|
|
693
|
+
if keep.line is not None and row.line == keep.line and row.note is None:
|
|
694
|
+
return number
|
|
695
|
+
return fallback or 0
|
|
696
|
+
|
|
697
|
+
def row_index(self, row: int) -> int:
|
|
698
|
+
return Document(self.page.text).translate_row_col_to_index(row, 0)
|
|
699
|
+
|
|
700
|
+
def go_to(self, row: int) -> None:
|
|
701
|
+
self.diff.buffer.cursor_position = self.diff.document.translate_row_col_to_index(row, 0)
|
|
702
|
+
self.diff.window.vertical_scroll = row
|
|
703
|
+
|
|
704
|
+
def current_row(self) -> Row | None:
|
|
705
|
+
number = self.diff.document.cursor_position_row
|
|
706
|
+
return self.page.rows[number] if number < len(self.page.rows) else None
|
|
707
|
+
|
|
708
|
+
def position(self) -> int:
|
|
709
|
+
row = self.diff.document.cursor_position_row
|
|
710
|
+
return sum(1 for start in self.page.starts if start <= row) if self.visible else 0
|
|
711
|
+
|
|
712
|
+
def follow_files(self) -> None:
|
|
713
|
+
"""Selecting a file scrolls the diff to it."""
|
|
714
|
+
if self._syncing or not self.visible:
|
|
715
|
+
return
|
|
716
|
+
index = self.files.document.cursor_position_row
|
|
717
|
+
if index < len(self.page.starts):
|
|
718
|
+
self._syncing = True
|
|
719
|
+
self.go_to(self.page.starts[index])
|
|
720
|
+
self._syncing = False
|
|
721
|
+
|
|
722
|
+
def follow_diff(self) -> None:
|
|
723
|
+
"""Moving in the diff keeps the file index on the file under the cursor."""
|
|
724
|
+
if self._syncing or not self.visible:
|
|
725
|
+
return
|
|
726
|
+
index = max(0, self.position() - 1)
|
|
727
|
+
self._syncing = True
|
|
728
|
+
self.files.buffer.cursor_position = self.files.document.translate_row_col_to_index(index, 0)
|
|
729
|
+
self._syncing = False
|
|
730
|
+
|
|
731
|
+
# Searching
|
|
732
|
+
|
|
733
|
+
def terms(self) -> list[str]:
|
|
734
|
+
return self.query.text.casefold().split()
|
|
735
|
+
|
|
736
|
+
def search(self, scope: str) -> None:
|
|
737
|
+
"""Retarget the query line; a query typed for the other scope is dropped."""
|
|
738
|
+
if scope != self.scope:
|
|
739
|
+
self.scope = scope
|
|
740
|
+
self.query.text = ""
|
|
741
|
+
self.app.layout.focus(self.query)
|
|
742
|
+
|
|
743
|
+
def matches(self, change: EditCompleted) -> bool:
|
|
744
|
+
terms = self.terms()
|
|
745
|
+
if self.scope == "paths":
|
|
746
|
+
return all(fuzzy_match(term, change.path.casefold()) for term in terms)
|
|
747
|
+
# Match the redacted text, so redaction cannot hide the row a query matched.
|
|
748
|
+
text = "\n".join([change_heading(change), patch_text(change.patch, dedent=self.dedent)])
|
|
749
|
+
return not terms or bool(matching_rows(text, terms))
|
|
750
|
+
|
|
751
|
+
def diff_rows(self) -> list[int]:
|
|
752
|
+
"""Diff-pane rows matching a diff search; the path search never highlights rows."""
|
|
753
|
+
if self.scope != "paths":
|
|
754
|
+
return matching_rows(self.page.text, self.terms())
|
|
755
|
+
return []
|
|
756
|
+
|
|
757
|
+
def jump(self, direction: int) -> None:
|
|
758
|
+
"""Move the diff cursor to the next or previous matching row, wrapping."""
|
|
759
|
+
rows = self.diff_rows()
|
|
760
|
+
if not rows:
|
|
761
|
+
return
|
|
762
|
+
current = self.diff.document.cursor_position_row
|
|
763
|
+
if direction > 0:
|
|
764
|
+
self.go_to(next((row for row in rows if row > current), rows[0]))
|
|
765
|
+
else:
|
|
766
|
+
self.go_to(next((row for row in reversed(rows) if row < current), rows[-1]))
|
|
767
|
+
|
|
768
|
+
# Acting on a row
|
|
769
|
+
|
|
770
|
+
def note_prompt(self) -> str:
|
|
771
|
+
row = self.editing.anchor if isinstance(self.editing, Note) else None
|
|
772
|
+
if isinstance(self.editing, Row) and self.editing.anchor is not None:
|
|
773
|
+
row = self.editing.anchor
|
|
774
|
+
line = self.editing.line if self.editing is not None else None
|
|
775
|
+
where = (row.path if row else "") + (f":{line}" if line else "")
|
|
776
|
+
return f"Note on {where} (Enter saves, empty deletes, Esc cancels): "
|
|
777
|
+
|
|
778
|
+
def start_note(self) -> None:
|
|
779
|
+
row = self.current_row()
|
|
780
|
+
if row is None or row.anchor is None:
|
|
781
|
+
self.notice = "Move to a diff line to add a note"
|
|
782
|
+
return
|
|
783
|
+
self.editing = row.note or row
|
|
784
|
+
self.note.text = row.note.text if row.note else ""
|
|
785
|
+
self.note.buffer.cursor_position = len(self.note.text)
|
|
786
|
+
self.app.layout.focus(self.note)
|
|
787
|
+
|
|
788
|
+
def save_note(self, text: str) -> None:
|
|
789
|
+
editing, self.editing = self.editing, None
|
|
790
|
+
text = " ".join(text.split())
|
|
791
|
+
if isinstance(editing, Note):
|
|
792
|
+
if text:
|
|
793
|
+
editing.text = text
|
|
794
|
+
current = self.current_row()
|
|
795
|
+
keep = Row(current.change if current else None, editing.line, None, editing)
|
|
796
|
+
else:
|
|
797
|
+
self.notes.remove(editing)
|
|
798
|
+
keep = self.current_row()
|
|
799
|
+
elif isinstance(editing, Row) and text and editing.anchor is not None:
|
|
800
|
+
note = Note(
|
|
801
|
+
editing.anchor, editing.line, quote_for(editing.change, editing.anchor), text
|
|
802
|
+
)
|
|
803
|
+
self.notes.append(note)
|
|
804
|
+
keep = replace(editing, note=note)
|
|
805
|
+
else:
|
|
806
|
+
return
|
|
807
|
+
self.refresh(keep=keep)
|
|
808
|
+
|
|
809
|
+
def mark_reviewed(self) -> None:
|
|
810
|
+
checkpoint = self.review.checkpoint
|
|
811
|
+
if self.mark is None or checkpoint is None:
|
|
812
|
+
self.notice = "Nothing to mark reviewed"
|
|
813
|
+
return
|
|
814
|
+
try:
|
|
815
|
+
self.mark(checkpoint)
|
|
816
|
+
except Exception as error: # the review stays usable without a checkpoint
|
|
817
|
+
self.notice = f"Could not mark reviewed: {error}"
|
|
818
|
+
return
|
|
819
|
+
self.review = replace(self.review, since_review=[])
|
|
820
|
+
self.notice = "Marked reviewed"
|
|
821
|
+
self.refresh()
|
|
822
|
+
|
|
823
|
+
def open_editor(self) -> None:
|
|
824
|
+
row = self.current_row()
|
|
825
|
+
if row is None or row.change is None or self.review.root is None:
|
|
826
|
+
self.notice = "Move to a file to open it"
|
|
827
|
+
return
|
|
828
|
+
path = self.review.root / file_path(row.change)
|
|
829
|
+
if not path.exists():
|
|
830
|
+
self.notice = f"{file_path(row.change)} no longer exists"
|
|
831
|
+
return
|
|
832
|
+
command = editor_command(path, row.line)
|
|
833
|
+
|
|
834
|
+
async def edit():
|
|
835
|
+
try:
|
|
836
|
+
with self.handoff():
|
|
837
|
+
await run_in_terminal(
|
|
838
|
+
lambda: subprocess.run(command, cwd=self.review.root, check=False),
|
|
839
|
+
in_executor=True,
|
|
840
|
+
)
|
|
841
|
+
except OSError as error:
|
|
842
|
+
self.notice = f"Could not start {command[0]}: {error}"
|
|
843
|
+
return
|
|
844
|
+
# The file may have changed; show what it holds now.
|
|
845
|
+
await self.refetch()
|
|
846
|
+
|
|
847
|
+
self.app.create_background_task(edit())
|
|
848
|
+
|
|
849
|
+
async def run(self) -> None:
|
|
850
|
+
await self.app.run_async()
|