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 pub(super) drawer_offset: i16,
29 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#[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#[derive(Default)]
59pub(super) struct PatchView {
60 pub(super) document: DocumentPane<PatchCursor>,
61}
62
63#[derive(Default)]
65pub(super) struct Review {
66 pub(super) queue: ReviewQueue,
67 pub(super) draft: Option<Draft<PatchAnchor>>,
68}
69
70#[derive(Default)]
72pub(super) struct Request {
73 pub(super) id: RequestId,
74 pub(super) in_flight: bool,
75 pub(super) pending: Option<PendingAction>,
77}
78
79pub(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 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 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 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 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 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 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 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 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 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}