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
//! 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};
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. Even over an
/// empty diff there this still opens: its own anchored files fill the
/// pane once they resolve, so that is not "nothing to review" here.
///
/// A PR `about` names isn't always resolved yet (a fresh session never
/// opened it, so `pr_ranges` has nothing for it): `resolve_walkthrough_pr`
/// resolves it the way opening that PR directly would, fetching its head
/// first when needed. Returns `false` while that resolution is still in
/// flight, leaving `self.diff` untouched; the caller retries once it
/// lands rather than opening a diff that renders the PR as unchanged.
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;
}
/// Shared ceremony for every diff opener: load the source's review state,
/// build a fresh `DiffView` from the current file-layout config, install
/// it as `self.diff`, and push the diff screen. On a source load failure
/// the error is reported and nothing changes (`self.diff` stays `None` or
/// keeps the previous view).
/// `allow_empty` skips the "nothing to review" refusal below for a caller
/// whose own files will fill the pane once they resolve (a walkthrough
/// over a clean tree): every other opener passes `false`.
fn install_diff_view(
&mut self,
source: ReviewSource,
model: Option<DiffModel>,
allow_empty: bool,
) {
// a source with no files has nothing to read and no line to comment on,
// so entering it strands the reader on an empty screen: say why instead.
// 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 land while a comment is being written: the draft
// rides along when it still belongs here, and is never dropped silently
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
// the rebuilt view has to land back on it
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 (forges
/// serve `refs/pull/<n>/head`) and the open retries when the fetch lands.
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 doesn't have it: the fetch lands as a
/// `git_finished` continuation keyed off `pending_pr_open`, so `None`
/// here means the caller must retry once that lands.
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);
}
let remote = self
.ci_remotes
.first()
.map_or_else(|| "origin".to_owned(), |r| r.name.clone());
let refspec = format!("refs/pull/{}/head", pr.number);
let base_ref = pr.base_ref.clone();
let label = Self::pr_fetch_label(pr.number);
self.pending_pr_open = Some(pr);
// the base ref comes along so merge-base reflects the forge's
// view, not however stale the last fetch left it
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`.
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())
}
/// Make sure `pr_ranges` holds `number`, the way opening that PR
/// directly resolves it: a PR already known locally resolves its range
/// or queues fetching its head; a number nobody has fetched yet queues
/// the open-PRs list and is looked up once it lands. `false` means
/// resolution is still in flight (a git fetch or a forge poll) and the
/// caller must retry once it completes.
pub(crate) fn resolve_walkthrough_pr(&mut self, number: u64) -> bool {
let Some(pr) = self.known_pr(number) else {
if self.status.prs_loaded {
self.error(format!(
"PR #{number} isn't among the repo's open pull requests; \
this walkthrough's diff can't be resolved"
));
} else if self.ci_remotes.is_empty() {
self.error(format!(
"no CI provider detected for this repo; \
can't resolve PR #{number} for this walkthrough"
));
} else if !self.status.prs_in_flight {
self.status.prs_in_flight = true;
self.pending_ci = Some(crate::app::CiRequest::Prs);
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 (a head-move refresh,
// or picking it again from the list) swaps the model in
// place: the reviewer keeps their cursor, folds, and screen
// stack instead of landing on a fresh view
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()),
}
}
}