1use std::sync::Arc;
2
3use anyhow::{Context, Result, ensure};
4
5use super::App;
6use crate::{
7 highlight::{FileSource, Reply},
8 input::SourceState,
9 model::RowKind,
10 storage::FileIndex,
11 view::LineId,
12};
13
14pub(crate) struct SourceStatus {
15 pub text: String,
16 pub attention: bool,
17}
18
19impl App {
20 pub(crate) fn source_at_cursor(&self, side: Option<bool>) -> Result<Option<(LineId, bool)>> {
21 let Some(row) = self.row(self.cursor.row) else {
22 return Ok(None);
23 };
24 let RowKind::Code { left, right } = row.kind else {
25 return Ok(None);
26 };
27 let (id, old) = if let Some(old) = side {
28 (if old { left } else { right }, old)
29 } else if self.split() {
30 (if self.old_side { left } else { right }, self.old_side)
31 } else if right.is_none() {
32 (left, true)
33 } else {
34 (right, left == right && self.old_side)
35 };
36 let Some(id) = id else {
37 return Ok(None);
38 };
39 if !crate::render::source_has_part(self, id, self.cursor.part, old)? {
40 return Ok(None);
41 }
42 Ok(Some((id, old)))
43 }
44
45 pub(crate) fn line_file(&self, id: LineId) -> &Arc<FileIndex> {
46 match id {
47 LineId::Patch(id) => self.doc.file_for_source(id),
48 LineId::Context { file, .. } => &self.doc.files[file],
49 }
50 }
51
52 pub(crate) fn line_info(&self, id: LineId) -> Result<crate::storage::LineInfo> {
53 match id {
54 LineId::Patch(id) => Ok(self.doc.info(id)),
55 LineId::Context { file, old, new } => self
56 .checked_source(file)?
57 .context_info(old.get(), new.get()),
58 }
59 }
60
61 fn checked_source(&self, file: usize) -> Result<&crate::source::CheckedPair> {
62 match self.sources.as_ref().and_then(|sources| sources.get(file)) {
63 Some(SourceState::Checked(pair)) => Ok(pair),
64 _ => anyhow::bail!("Context requires checked source"),
65 }
66 }
67
68 pub(crate) fn line_geometry(
69 &self,
70 id: LineId,
71 width: Option<usize>,
72 ) -> Result<std::rc::Rc<crate::geometry::Line>> {
73 match id {
74 LineId::Patch(id) => self.geometry.get(&self.doc, id, width),
75 LineId::Context { file, old, new } => {
76 let pair = self.checked_source(file)?;
77 pair.context_info(old.get(), new.get())?;
78 let source = pair.new_source().context("Checked context has no source")?;
79 let (store, reference) = source.stored_line(new.get())?;
80 self.geometry.get_source(store, reference, width)
81 }
82 }
83 }
84
85 pub(crate) fn line_digits(&self, file: usize) -> usize {
86 let mut digits = self.doc.files[file].digits;
87 if let Some(SourceState::Checked(pair)) =
88 self.sources.as_ref().and_then(|sources| sources.get(file))
89 {
90 let lines = pair
91 .old()
92 .into_iter()
93 .chain(pair.new_source())
94 .map(|source| source.lines())
95 .max()
96 .unwrap_or(0);
97 digits = digits.max(lines.checked_ilog10().unwrap_or(0) as usize + 1);
98 }
99 digits
100 }
101
102 pub(super) fn coloring_source(&self, file: &Arc<FileIndex>) -> FileSource {
103 match &self.sources {
104 None => FileSource::Patch(file.clone()),
105 Some(sources) => match sources.get(file.id) {
106 Some(SourceState::Checked(pair)) => FileSource::Complete(pair.clone()),
107 _ => FileSource::Plain(file.clone()),
108 },
109 }
110 }
111
112 pub fn set_source(&mut self, file: usize, state: SourceState) -> Result<()> {
113 let entry = self
114 .doc
115 .files
116 .get(file)
117 .context("Source reply is outside the current review")?;
118 if let SourceState::Checked(pair) = &state {
119 ensure!(
120 Arc::ptr_eq(entry, pair.file()),
121 "Source reply belongs to a different patch entry"
122 );
123 }
124 ensure!(
125 self.sources
126 .as_ref()
127 .is_some_and(|sources| file < sources.len()),
128 "Source reply is outside the current review"
129 );
130 let same_source = matches!(
131 (self.sources.as_ref().and_then(|sources| sources.get(file)), &state),
132 (Some(SourceState::Checked(previous)), SourceState::Checked(next))
133 if Arc::ptr_eq(previous, next)
134 );
135 let split = self.split();
136 let cursor_file = self.row(self.cursor.row).map(|row| row.file);
137 let digits = cursor_file.map(|file| self.line_digits(file));
138 let cursor_source = self.anchor();
139 let top_source = self.row(self.top.row).and_then(|row| match row.kind {
140 RowKind::Code { left, right } => left.or(right),
141 _ => None,
142 });
143 let cursor_row = self.view.original_row(&self.doc, self.cursor.row, split);
144 let top_row = self.view.original_row(&self.doc, self.top.row, split);
145 let removing = !matches!(state, SourceState::Checked(_));
146 if removing && matches!(cursor_source, Some(LineId::Context { file: id, .. }) if id == file)
147 {
148 self.reading_anchor = None;
149 } else {
150 self.remember_position(None)?;
151 }
152 self.view.set(
153 &self.doc,
154 file,
155 match &state {
156 SourceState::Checked(pair) => Some(pair),
157 _ => None,
158 },
159 )?;
160 if !same_source {
161 self.source_reads.invalidate(file);
162 if self
163 .selection
164 .as_ref()
165 .is_some_and(|selection| selection.range.file == file && selection.checked)
166 {
167 self.selection = None;
168 }
169 }
170 if let Some(selection) = &mut self.selection
171 && selection.range.file == file
172 && matches!(state, SourceState::Checked(_))
173 {
174 selection.checked = true;
175 }
176 self.cache.invalidate(&self.doc.files[file]);
177 if let Some(sources) = &mut self.sources {
178 sources[file] = state;
179 }
180 self.cursor.row = cursor_source
181 .and_then(|id| self.view.source_row(&self.doc, id, split))
182 .unwrap_or_else(|| self.view.patch_row(&self.doc, cursor_row, split));
183 self.top.row = top_source
184 .and_then(|id| self.view.source_row(&self.doc, id, split))
185 .unwrap_or_else(|| self.view.patch_row(&self.doc, top_row, split));
186 self.cursor.part = self
187 .cursor
188 .part
189 .min(self.row_height(self.cursor.row)?.saturating_sub(1));
190 self.top.part = self
191 .top
192 .part
193 .min(self.row_height(self.top.row)?.saturating_sub(1));
194 if self.reading_anchor.is_some() || digits != cursor_file.map(|file| self.line_digits(file))
195 {
196 self.reflow(self.layout, self.width, self.height)?;
197 }
198 self.visible.clear();
199 self.worker.cancel();
200 Ok(())
201 }
202
203 pub fn accept_highlight(&mut self, reply: Reply) -> Result<bool> {
204 if reply.epoch != self.epoch
205 || match reply.id {
206 LineId::Patch(id) => id >= self.doc.source_len(),
207 LineId::Context { file, .. } => file >= self.doc.files.len(),
208 }
209 {
210 return Ok(false);
211 }
212 let file = self.line_file(reply.id);
213 if reply.origin != self.coloring_source(file).origin() {
214 return Ok(false);
215 }
216 if let Some(reason) = reply.source_error {
217 self.set_source(file.id, SourceState::Unavailable(reason))?;
218 return Ok(true);
219 }
220 self.cache.insert(reply.id, reply.style);
221 Ok(true)
222 }
223
224 pub(crate) fn source_status(&self) -> Option<SourceStatus> {
227 let sources = self.sources.as_ref()?;
228 let cursor = self.row(self.cursor.row);
229 let file = cursor.map(|row| row.file);
230 let (mut preparing, mut checked) = (false, false);
231 match file.and_then(|file| sources.get(file)) {
232 Some(SourceState::Unavailable(reason)) => {
233 return Some(SourceStatus {
234 text: format!("Source unavailable: {reason} · patch view retained"),
235 attention: true,
236 });
237 }
238 Some(SourceState::Pending) => preparing = true,
239 Some(SourceState::Checked(_)) => checked = true,
240 _ => {}
241 }
242 let mut pending = false;
243 let mut fallback = None;
244 for row in self
245 .visible
246 .iter()
247 .filter_map(|(position, _)| self.row(position.row))
248 .filter(|row| Some(row.file) == file)
249 .chain(cursor)
250 {
251 if let RowKind::Code { left, right } = row.kind {
252 for id in left.into_iter().chain(right) {
253 pending |= self.cache.get(id).is_none();
254 if let Some(reason) = self.cache.get(id).and_then(|style| style.fallback_reason)
255 {
256 fallback.get_or_insert(reason);
257 }
258 }
259 }
260 }
261 let text = if let Some(reason) = fallback {
262 reason.status().into()
263 } else if preparing {
264 "Preparing source · plain syntax".into()
265 } else if checked && pending {
266 "Source checked · coloring visible code…".into()
267 } else if checked {
268 "Source checked · complete-context coloring".into()
269 } else {
270 "Metadata only".into()
271 };
272 Some(SourceStatus {
273 text,
274 attention: fallback.is_some() || preparing || checked && pending,
275 })
276 }
277}