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
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
//! Opening the diff screen on one review source: the working tree, a commit,
//! a range, a three-dot base, or a pull request.
use diffler_core::model::DiffModel;
use diffler_core::source::ReviewSource;
use super::{DiffView, Pane};
use crate::app::{App, Screen};
pub(crate) enum PrLookup {
Known(crate::ci::PullRequest),
/// The forge's open list came back without it.
NotOpen,
NoForge,
Loading,
}
impl App {
/// Open the full working-tree diff with the sidebar focused at the first
/// file (`D` / section headers / commit-from-log model).
pub(crate) fn open_working_tree_diff(&mut self, scope: Option<&str>) {
self.open_working_tree_diff_focused(scope, Pane::List);
}
/// Open a single file's diff with the diff pane focused (`<cr>` on a
/// status file row).
pub(crate) fn open_working_tree_file(&mut self, path: &str) {
self.open_working_tree_diff_focused(Some(path), Pane::Diff);
}
/// Open the walkthrough `id` as its own review source, rendering whatever
/// review it is about: the working tree by default, or the commit,
/// range, or PR the human had open when it was published. It opens over
/// an empty diff too, since its anchored files fill the pane once they
/// resolve. Returns `false` while a PR's range is still resolving, leaving
/// `self.diff` untouched for the caller to retry.
pub(crate) fn open_walkthrough_diff(&mut self, id: &str) -> bool {
let about = self.walkthrough_about(id);
if let ReviewSource::Pr { number } = about
&& !self.pr_ranges.contains_key(&number)
&& !self.resolve_walkthrough_pr(number)
{
return false;
}
let model = match about {
ReviewSource::WorkingTree => None,
other => Some((*self.source_model(&other)).clone()),
};
self.install_diff_view(ReviewSource::Walkthrough { id: id.to_owned() }, model, true);
true
}
fn open_working_tree_diff_focused(&mut self, scope: Option<&str>, focus: Pane) {
self.install_diff_view(ReviewSource::WorkingTree, None, false);
let Some(view) = self.diff.as_mut() else {
return;
};
if let Some(path) = scope
&& let Some(index) = self
.review
.model()
.files
.iter()
.position(|f| f.path == path)
{
view.selected = index;
view.invalidate();
view.ensure_rows(&self.review);
}
view.focus = focus;
}
/// Load the source's review state, install a fresh `DiffView` and push the
/// diff screen. A load failure is reported and changes nothing.
/// `allow_empty` skips the empty-diff refusal, for a walkthrough whose own
/// files fill the pane once they resolve.
fn install_diff_view(
&mut self,
source: ReviewSource,
model: Option<DiffModel>,
allow_empty: bool,
) {
// we refuse to open a source with no files and say why; a review
// already open stays open when its diff empties out
let files = model.as_ref().map_or_else(
|| self.review.model().files.len(),
|model| model.files.len(),
);
if files == 0 && !allow_empty {
self.info(if source == ReviewSource::WorkingTree {
"nothing to review: working tree clean".to_owned()
} else {
format!("nothing to review in {}", source.label())
});
return;
}
if let Err(err) = self.review.ensure_source(&source) {
self.error(err.to_string());
return;
}
// a queued open can arrive while a comment is being written, so we
// carry the draft over when it still belongs here
let open = self.diff.take();
let same_source = open.as_ref().is_some_and(|open| open.source == source);
let drafted_path = open
.as_ref()
.filter(|_| same_source)
.and_then(|open| open.composer.as_ref().and(open.selected_path(&self.review)));
let draft = open.and_then(|open| open.composer);
let mut view = DiffView::new(
source,
model,
&self.review,
self.config.ui.diff_file_layout,
self.config.classify.rules(),
self.config.ui.side_by_side,
);
match draft {
Some(draft) if same_source => {
// the composer only draws on the file it is anchored to, so
// we select that file in the rebuilt view
if let Some(index) = drafted_path.and_then(|path| {
view.model(&self.review)
.files
.iter()
.position(|file| file.path == path)
}) {
view.selected = index;
}
view.composer = Some(draft);
view.invalidate();
view.ensure_rows(&self.review);
}
Some(draft) if !draft.buffer.trim().is_empty() => {
self.error("the diff moved; your unsent draft was dropped");
}
_ => {}
}
self.diff = Some(view);
self.queue_declared();
self.ensure_walkthrough_view();
self.push_screen(Screen::Diff);
}
pub(crate) fn open_commit_diff(&mut self, oid: &str) {
match self.review.vcs.commit_diff(oid) {
Ok(model) => self.install_diff_view(ReviewSource::commit(oid), Some(model), false),
Err(err) => self.error(err.to_string()),
}
}
/// Review everything the working tree carries over `rev`: the branch's
/// commits plus whatever is still uncommitted. The model tracks edits, so
/// the off-thread refresh recomputes it (see `App::against_rev`).
pub(crate) fn open_against_diff(&mut self, rev: &str) {
match diffler_core::vcs::against_diff(self.review.vcs.as_ref(), rev) {
Ok(model) => {
let source = ReviewSource::against(rev);
if let Some(diff) = self.diff.as_ref().filter(|d| d.source == source) {
// capture before the model swap, or the position named
// would already read against the row it is moving to
let positions = diff.capture_positions(&self.review);
self.finish_diff_swap(positions, Some(model));
} else {
self.install_diff_view(source, Some(model), false);
}
}
Err(err) => self.error(err.to_string()),
}
}
/// The `Against` diff for `rev` outside the render path (agent tool calls):
/// the open view's model when it is showing that rev, else a fresh compute.
/// A backend error degrades to an empty diff, like the cached sources.
pub(crate) fn against_model_for(&self, rev: &str) -> DiffModel {
self.diff
.as_ref()
.filter(|d| d.source == ReviewSource::against(rev))
.and_then(|d| d.commit_model.clone())
.unwrap_or_else(|| {
diffler_core::vcs::against_diff(self.review.vcs.as_ref(), rev).unwrap_or_default()
})
}
/// The rev of the open `Against` review, so the refresh worker recomputes
/// its diff alongside the working tree.
pub fn against_rev(&self) -> Option<&str> {
match self.diff.as_ref().map(|d| &d.source) {
Some(ReviewSource::Against { rev }) => Some(rev),
_ => None,
}
}
/// Open the combined diff of a contiguous commit range (oldest to newest,
/// full oids), pinned like a single commit's diff.
pub(crate) fn open_range_diff(&mut self, oldest: &str, newest: &str) {
match self.review.vcs.range_diff(oldest, newest) {
Ok(model) => {
self.install_diff_view(ReviewSource::range(oldest, newest), Some(model), false);
}
Err(err) => self.error(err.to_string()),
}
}
/// Review the branch's open PR: diff `merge-base..head` under the stable
/// `pr-<n>` source. A head we don't have yet is fetched first, from the
/// ref the forge serves it under, and the open retries after the fetch.
pub(crate) fn open_pr_review(&mut self) {
let Some(pr) = self.pr.clone() else {
self.info("no open PR detected for this branch");
return;
};
self.open_pr_review_for(pr);
}
/// Review any PR, including one whose branch was never checked out; the
/// diff needs only the fetched objects.
pub(crate) fn open_pr_review_for(&mut self, pr: crate::ci::PullRequest) {
let number = pr.number;
if let Some((base, head)) = self.ensure_pr_range(pr) {
self.open_pr_diff(number, &base, &head);
}
}
/// `(merge_base, head)` for `pr` against the local objects, fetching its
/// head first when the repository lacks it. `None` means the caller
/// retries from the `git_finished` continuation keyed off
/// `pending_pr_open`.
pub(crate) fn ensure_pr_range(
&mut self,
pr: crate::ci::PullRequest,
) -> Option<(String, String)> {
if let Some(range) = self.resolve_pr_range(&pr) {
return Some(range);
}
if self.declines_pr_fetch() {
return None;
}
let (remote, refspec) = self.pr_head_source(pr.number);
let base_ref = pr.base_ref.clone();
let label = Self::pr_fetch_label(pr.number);
self.pending_pr_open = Some(pr);
// we fetch the base ref too so merge-base matches the forge's view
self.pending_git = Some(crate::app::GitOp {
label,
argv: vec![
"git".to_owned(),
"fetch".to_owned(),
remote,
refspec,
base_ref,
],
});
None
}
/// `(merge_base, head)` for the PR against the local objects; `None` when
/// the head hasn't been fetched yet.
pub(crate) fn resolve_pr_range(&self, pr: &crate::ci::PullRequest) -> Option<(String, String)> {
let head = self.review.vcs.resolve(&pr.head_oid).ok()?;
let base_tip = self
.ci_remotes
.first()
.and_then(|r| {
self.review
.vcs
.resolve(&format!("refs/remotes/{}/{}", r.name, pr.base_ref))
.ok()
})
.or_else(|| self.review.vcs.resolve(&pr.base_ref).ok())?;
let base = self.review.vcs.merge_base(&base_tip, &head).ok()?;
Some((base, head))
}
/// The PR the branch's own current review or the fetched open-PRs list
/// already knows about, if either names `number`.
pub(crate) fn known_pr(&self, number: u64) -> Option<crate::ci::PullRequest> {
self.pr
.clone()
.filter(|pr| pr.number == number)
.or_else(|| self.prs.iter().find(|pr| pr.number == number).cloned())
}
/// The PR `number` names among the ones this session knows, asking the
/// forge for the open list when it has not answered yet.
pub(crate) fn find_pr(&mut self, number: u64) -> PrLookup {
if let Some(pr) = self.known_pr(number) {
return PrLookup::Known(pr);
}
if self.status.prs_loaded {
return PrLookup::NotOpen;
}
if self.ci_remotes.is_empty() {
return PrLookup::NoForge;
}
self.request_pr_list();
PrLookup::Loading
}
pub(crate) fn request_pr_list(&mut self) {
if !self.status.prs_in_flight {
self.status.prs_in_flight = true;
self.pending_ci = Some(crate::app::CiRequest::Prs);
}
}
/// Make sure `pr_ranges` holds `number`, queueing a head fetch or the
/// open-PRs list when needed. `false` means resolution is still in flight
/// and the caller retries once it completes.
pub(crate) fn resolve_walkthrough_pr(&mut self, number: u64) -> bool {
let pr = match self.find_pr(number) {
PrLookup::Known(pr) => pr,
PrLookup::NotOpen => {
self.error(format!(
"PR #{number} isn't among the repo's open pull requests; \
this walkthrough's diff can't be resolved"
));
return false;
}
PrLookup::NoForge => {
self.error(format!(
"no CI provider detected for this repo; \
can't resolve PR #{number} for this walkthrough"
));
return false;
}
PrLookup::Loading => {
self.info(format!("loading PR #{number} for this walkthrough"));
return false;
}
};
if let Some((base, head)) = self.ensure_pr_range(pr) {
self.pr_ranges.insert(number, (base, head));
true
} else {
self.info(format!("fetching PR #{number} for this walkthrough"));
false
}
}
pub(crate) fn open_pr_diff(&mut self, number: u64, base: &str, head: &str) {
match self.review.vcs.tree_diff(base, head) {
Ok(model) => {
self.pr_ranges
.insert(number, (base.to_owned(), head.to_owned()));
let source = ReviewSource::pr(number);
if let Err(err) = self.review.ensure_source(&source) {
self.error(err.to_string());
return;
}
// re-opening the PR already on screen swaps the model in place,
// so the reviewer keeps their cursor, folds and screen stack
if let Some(diff) = self.diff.as_ref().filter(|d| d.source == source) {
// capture before the model swap, or the position named
// would already read against the row it is moving to
let positions = diff.capture_positions(&self.review);
self.finish_diff_swap(positions, Some(model));
} else {
self.install_diff_view(source, Some(model), false);
}
self.pending_ci = Some(crate::app::CiRequest::PrComments(number));
}
Err(err) => self.error(err.to_string()),
}
}
}