Skip to main content

diffs_rs/app/
source.rs

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    /// Describes the source of the cursor's file. Other files visible on the same screen keep
225    /// their own state, which the footer reports once the cursor reaches them.
226    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}