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