Skip to main content

wisp/screens/git_diff/
state.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3
4use crate::screens::annotation::{Draft, Row};
5use crate::git_review::{DiffDocument, DiffScope, FileDiff, FileStatus, PatchAnchor, ReviewQueue, StageState};
6use crate::screens::review::{DocumentPane, Pane};
7use crate::view::edit_buffer::EditBuffer;
8use crate::view::selection::{Direction, SelectionState};
9
10use crate::surfaces::input::{GitReviewOutput, ReviewOutcome};
11
12use super::task;
13
14use crate::command::GitCommand;
15use crate::request::RequestId;
16
17pub struct GitDiffScreen {
18    pub(super) working_dir: PathBuf,
19    pub(super) repo_root: Option<PathBuf>,
20    pub(super) scope: DiffScope,
21    pub(super) state: GitDiffLoadState,
22    pub(super) selected_file: usize,
23    pub(super) drawer_selection: SelectionState,
24    pub(super) focus: Pane,
25    pub(super) collapsed: HashSet<String>,
26    /// Columns the reviewer has widened the file drawer by, relative to the
27    /// width the body would give it on its own.
28    pub(super) drawer_offset: i16,
29    /// The flattened file tree the drawer draws. Rebuilt only when the document
30    /// or the collapsed set changes, rather than on every key, click, and frame.
31    drawer_entries: Vec<DrawerEntry>,
32    pub(super) bottom_bar: BottomBar,
33    pub(super) full_file: FullFileView,
34    pub(super) patch: PatchView,
35    pub(super) review: Review,
36    pub(super) request: Request,
37}
38
39/// Whether the patch pane shows the diff or the whole file. Keeping the load
40/// in the same enum makes "on but not loaded yet" and "on and loaded" the only
41/// on states, so neither has to be inferred from a flag-and-option pair.
42#[derive(Default)]
43pub(super) enum FullFileView {
44    #[default]
45    Off,
46    Loading,
47    Loaded(String),
48}
49
50impl FullFileView {
51    pub(super) fn is_on(&self) -> bool {
52        !matches!(self, Self::Off)
53    }
54}
55
56/// The patch pane: the document under review.
57#[derive(Default)]
58pub(super) struct PatchView {
59    pub(super) document: DocumentPane<PatchCursor>,
60}
61
62/// The review being assembled: comments already filed, and the one being typed.
63#[derive(Default)]
64pub(super) struct Review {
65    pub(super) queue: ReviewQueue,
66    pub(super) draft: Option<Draft<PatchAnchor>>,
67}
68
69/// The git operation in flight, if any.
70#[derive(Default)]
71pub(super) struct Request {
72    pub(super) id: RequestId,
73    pub(super) in_flight: bool,
74    /// A destructive action armed and waiting for its key to be pressed again.
75    pub(super) pending: Option<PendingAction>,
76}
77
78/// One rendered row of a patch.
79pub(super) type PatchRow = Row<PatchCursor>;
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub(super) enum PendingAction {
83    Reload,
84    ScopeSwitch,
85    Stage,
86    Commit,
87    Discard,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
91pub(super) struct PatchCursor {
92    pub(super) hunk: usize,
93    pub(super) line: usize,
94}
95
96pub(super) enum GitDiffLoadState {
97    Loading {
98        /// The path selected before the reload, so the reloaded document can
99        /// put the selection back on the same file.
100        restore_path: Option<String>,
101    },
102    Ready(DiffDocument),
103    Error(String),
104}
105
106#[derive(Clone)]
107pub(super) enum DrawerEntry {
108    Directory { path: String, depth: usize },
109    File { index: usize, depth: usize },
110}
111
112pub(super) enum BottomBar {
113    Help,
114    CommitEditor { buffer: EditBuffer },
115    DiscardConfirmation { path: String, status: FileStatus },
116    Error(String),
117}
118
119impl GitDiffScreen {
120    pub fn new(working_dir: PathBuf) -> (Self, GitCommand) {
121        let mut screen = Self {
122            working_dir,
123            repo_root: None,
124            scope: DiffScope::default(),
125            state: GitDiffLoadState::Loading { restore_path: None },
126            selected_file: 0,
127            drawer_selection: SelectionState::default(),
128            focus: Pane::Nav,
129            collapsed: HashSet::new(),
130            drawer_offset: 0,
131            drawer_entries: Vec::new(),
132            bottom_bar: BottomBar::Help,
133            full_file: FullFileView::default(),
134            patch: PatchView::default(),
135            review: Review::default(),
136            request: Request::default(),
137        };
138        let task = screen.begin_load();
139        (screen, task)
140    }
141
142    pub(super) fn begin_load(&mut self) -> GitCommand {
143        let restore_path = self.selected_file().map(|file| file.path.clone());
144        self.request.pending = None;
145        self.review.queue.clear();
146        self.state = GitDiffLoadState::Loading { restore_path };
147        GitCommand::Load {
148            request_id: self.begin_request(),
149            working_dir: self.working_dir.clone(),
150            repo_root: self.repo_root.clone(),
151            scope: self.scope,
152        }
153    }
154
155    pub(super) fn apply_document(&mut self, document: DiffDocument) {
156        self.repo_root = Some(document.repo_root.clone());
157        let restore_path = match &self.state {
158            GitDiffLoadState::Loading { restore_path } => restore_path.clone(),
159            GitDiffLoadState::Ready(_) | GitDiffLoadState::Error(_) => None,
160        };
161        self.selected_file = restore_path
162            .as_deref()
163            .and_then(|path| document.files.iter().position(|file| file.path == path))
164            .unwrap_or(0)
165            .min(document.files.len().saturating_sub(1));
166        self.patch.document.cursor = PatchCursor::default();
167        self.state = GitDiffLoadState::Ready(document);
168        self.rebuild_drawer();
169    }
170
171    pub(super) fn stage_all(&mut self) -> Vec<GitReviewOutput> {
172        self.repo_operation(|request_id, repo_root| GitCommand::StageAll { request_id, repo_root })
173    }
174
175    pub(super) fn unstage_all(&mut self) -> Vec<GitReviewOutput> {
176        self.repo_operation(|request_id, repo_root| GitCommand::UnstageAll { request_id, repo_root })
177    }
178
179    pub(super) fn toggle_stage(&mut self) -> Vec<GitReviewOutput> {
180        let Some(entry) = self.selected_drawer_entry().cloned() else {
181            return Vec::new();
182        };
183        let files = self.files_for_entry(&entry);
184        if files.is_empty() {
185            return Vec::new();
186        }
187        let all_staged = files.iter().all(|file| file.staged == StageState::Staged);
188        let paths: Vec<String> = files.iter().map(|file| file.path.clone()).collect();
189        self.repo_operation(move |request_id, repo_root| {
190            if all_staged {
191                GitCommand::UnstageFiles { request_id, repo_root, paths }
192            } else {
193                GitCommand::StageFiles { request_id, repo_root, paths }
194            }
195        })
196    }
197
198    pub(super) fn begin_commit(&mut self) -> Vec<GitReviewOutput> {
199        if self.request.in_flight {
200            return Vec::new();
201        }
202        if !matches!(&self.state, GitDiffLoadState::Ready(document)
203            if document.files.iter().any(|file| matches!(file.staged, StageState::Staged | StageState::PartiallyStaged)))
204        {
205            self.bottom_bar = BottomBar::Error("Nothing staged to commit".to_string());
206            return Vec::new();
207        }
208        self.bottom_bar = BottomBar::CommitEditor { buffer: EditBuffer::default() };
209        Vec::new()
210    }
211
212    pub(super) fn begin_discard(&mut self) -> Vec<GitReviewOutput> {
213        if self.request.in_flight {
214            return Vec::new();
215        }
216        let Some(file) = self.selected_file().cloned() else {
217            return Vec::new();
218        };
219        self.bottom_bar = BottomBar::DiscardConfirmation { path: file.path.clone(), status: file.status };
220        Vec::new()
221    }
222
223    pub(super) fn toggle_full_file(&mut self) -> Vec<GitReviewOutput> {
224        if self.request.in_flight {
225            return Vec::new();
226        }
227        if self.full_file.is_on() {
228            self.full_file = FullFileView::Off;
229            return Vec::new();
230        }
231        let Some(path) = self.selected_file().map(|file| file.path.clone()) else {
232            return Vec::new();
233        };
234        let messages =
235            self.repo_operation(|request_id, repo_root| GitCommand::LoadFullFile { request_id, repo_root, path });
236        if !messages.is_empty() {
237            self.full_file = FullFileView::Loading;
238        }
239        messages
240    }
241
242    /// Moves the focused pane's cursor by one entry.
243    pub(super) fn move_vertical(&mut self, direction: Direction) {
244        if self.focus == Pane::Document {
245            self.move_patch_cursor(direction, 1);
246            return;
247        }
248        if self.drawer_entries.is_empty() {
249            return;
250        }
251        self.drawer_selection.step_clamped(self.drawer_entries.len(), direction, |_| true);
252        self.follow_drawer_selection();
253    }
254
255    /// Widens the file drawer by `columns`, or narrows it when negative. The
256    /// next frame clamps the result to what the body can honour.
257    pub(super) fn resize_drawer(&mut self, columns: i16) -> Vec<GitReviewOutput> {
258        if columns < 0 && self.drawer_offset <= -20 {
259            return Vec::new();
260        }
261        if columns > 0 && self.drawer_offset < -20 {
262            self.drawer_offset = -20;
263        } else {
264            self.drawer_offset = self.drawer_offset.saturating_add(columns);
265        }
266        Vec::new()
267    }
268
269    /// Points the patch pane at the file the drawer selection landed on, if it
270    /// landed on a file rather than a directory.
271    pub(super) fn follow_drawer_selection(&mut self) {
272        if let Some(DrawerEntry::File { index, .. }) = self.selected_drawer_entry() {
273            self.selected_file = *index;
274        }
275    }
276
277    pub(super) fn selected_drawer_entry(&self) -> Option<&DrawerEntry> {
278        self.drawer_selection.selected().and_then(|selected| self.drawer_entries.get(selected))
279    }
280
281    /// Moves the patch cursor `amount` patch lines, flattening hunk boundaries
282    /// so it walks the file continuously.
283    pub(super) fn move_patch_cursor(&mut self, direction: Direction, amount: usize) {
284        let Some(file) = self.selected_file() else {
285            return;
286        };
287        let total = file.hunks.iter().map(|hunk| hunk.lines.len()).sum::<usize>();
288        let Some(last) = total.checked_sub(1) else {
289            return;
290        };
291        let cursor = self.patch.document.cursor;
292        let current = file
293            .hunks
294            .iter()
295            .take(cursor.hunk)
296            .map(|hunk| hunk.lines.len())
297            .sum::<usize>()
298            .saturating_add(cursor.line)
299            .min(last);
300        let next = match direction {
301            Direction::Backward => current.saturating_sub(amount),
302            Direction::Forward => current.saturating_add(amount).min(last),
303        };
304        let mut remaining = next;
305        for (hunk, entry) in file.hunks.iter().enumerate() {
306            if remaining < entry.lines.len() {
307                self.patch.document.cursor = PatchCursor { hunk, line: remaining };
308                break;
309            }
310            remaining -= entry.lines.len();
311        }
312        self.patch.document.follow_cursor();
313    }
314
315    pub(super) fn move_patch_scroll(&mut self, direction: Direction, amount: usize) {
316        self.patch.document.scroll_by(direction, amount);
317    }
318
319    pub(super) fn begin_draft(&mut self) -> Vec<GitReviewOutput> {
320        let cursor = self.patch.document.cursor;
321        let anchored = self
322            .selected_file()
323            .and_then(|file| file.hunks.get(cursor.hunk))
324            .is_some_and(|hunk| hunk.lines.len() > cursor.line);
325        if anchored {
326            let anchor = PatchAnchor { file_index: self.selected_file, hunk: cursor.hunk, line: cursor.line };
327            self.review.draft = Some(Draft::new(anchor));
328        }
329        Vec::new()
330    }
331
332    pub(super) fn undo_last_comment(&mut self) -> Vec<GitReviewOutput> {
333        self.review.queue.pop();
334        Vec::new()
335    }
336
337    pub(super) fn submit_review(&mut self) -> Vec<GitReviewOutput> {
338        if self.review.queue.is_empty() {
339            self.bottom_bar = BottomBar::Error("No comments to submit".to_string());
340            return Vec::new();
341        }
342        if self.request.in_flight {
343            self.bottom_bar = BottomBar::Error("Already submitting".to_string());
344            return Vec::new();
345        }
346        vec![GitReviewOutput::Outcome(ReviewOutcome::Submitted(self.review.queue.format_prompt()))]
347    }
348
349    /// Claims the next request id and marks an operation in flight, so results
350    /// for anything older are dropped.
351    pub(super) fn begin_request(&mut self) -> RequestId {
352        self.request.id = RequestId::next();
353        self.request.in_flight = true;
354        self.request.id
355    }
356
357    pub(super) fn collapse_selected(&mut self) {
358        if let Some(DrawerEntry::Directory { path, .. }) = self.selected_drawer_entry() {
359            let path = path.clone();
360            self.collapsed.insert(path);
361            self.rebuild_drawer();
362        }
363    }
364
365    /// Expands the selected directory, or points the patch pane at the selected
366    /// file. Reports whether it was a directory.
367    pub(super) fn expand_or_open_selected(&mut self) -> bool {
368        match self.selected_drawer_entry() {
369            Some(DrawerEntry::Directory { path, .. }) => {
370                let path = path.clone();
371                self.collapsed.remove(&path);
372                self.rebuild_drawer();
373                true
374            }
375            Some(DrawerEntry::File { index, .. }) => {
376                self.selected_file = *index;
377                false
378            }
379            None => false,
380        }
381    }
382
383    pub(super) fn drawer_entries(&self) -> &[DrawerEntry] {
384        &self.drawer_entries
385    }
386
387    /// Recomputes the file tree and puts the selection back on the current file.
388    fn rebuild_drawer(&mut self) {
389        self.drawer_entries = self.build_drawer_entries();
390        let selected = self
391            .drawer_entries
392            .iter()
393            .position(|entry| matches!(entry, DrawerEntry::File { index, .. } if *index == self.selected_file))
394            .unwrap_or(0);
395        self.drawer_selection.select(Some(selected), self.drawer_entries.len());
396    }
397
398    pub(super) fn selected_file(&self) -> Option<&FileDiff> {
399        self.file_at(self.selected_file)
400    }
401
402    fn build_drawer_entries(&self) -> Vec<DrawerEntry> {
403        let GitDiffLoadState::Ready(document) = &self.state else {
404            return Vec::new();
405        };
406        let mut entries = Vec::new();
407        let mut emitted = HashSet::new();
408        for (index, file) in document.files.iter().enumerate() {
409            let parts: Vec<&str> = file.path.split('/').collect();
410            let mut parent = String::new();
411            let mut hidden = false;
412            for (depth, part) in parts.iter().take(parts.len().saturating_sub(1)).enumerate() {
413                if !parent.is_empty() {
414                    parent.push('/');
415                }
416                parent.push_str(part);
417                if hidden {
418                    continue;
419                }
420                if emitted.insert(parent.clone()) {
421                    entries.push(DrawerEntry::Directory { path: parent.clone(), depth });
422                }
423                if self.collapsed.contains(&parent) {
424                    hidden = true;
425                }
426            }
427            if !hidden {
428                entries.push(DrawerEntry::File { index, depth: parts.len().saturating_sub(1) });
429            }
430        }
431        entries
432    }
433
434    pub(super) fn files_for_entry(&self, entry: &DrawerEntry) -> Vec<&FileDiff> {
435        let GitDiffLoadState::Ready(document) = &self.state else {
436            return Vec::new();
437        };
438        match entry {
439            DrawerEntry::Directory { path, .. } => {
440                let prefix = format!("{path}/");
441                document.files.iter().filter(|file| file.path.starts_with(&prefix)).collect()
442            }
443            DrawerEntry::File { index, .. } => document.files.get(*index).into_iter().collect(),
444        }
445    }
446
447    pub(super) fn file_at(&self, index: usize) -> Option<&FileDiff> {
448        let GitDiffLoadState::Ready(document) = &self.state else {
449            return None;
450        };
451        document.files.get(index)
452    }
453
454    /// Runs `build` against the repository root, doing nothing when the diff has
455    /// not yet reported one.
456    pub(super) fn repo_operation(
457        &mut self,
458        build: impl FnOnce(RequestId, PathBuf) -> GitCommand,
459    ) -> Vec<GitReviewOutput> {
460        let Some(repo_root) = self.repo_root.clone() else {
461            return Vec::new();
462        };
463        task(build(self.begin_request(), repo_root))
464    }
465}