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) request: Request,
37}
38
39#[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#[derive(Default)]
58pub(super) struct PatchView {
59 pub(super) document: DocumentPane<PatchCursor>,
60}
61
62#[derive(Default)]
64pub(super) struct Review {
65 pub(super) queue: ReviewQueue,
66 pub(super) draft: Option<Draft<PatchAnchor>>,
67}
68
69#[derive(Default)]
71pub(super) struct Request {
72 pub(super) id: RequestId,
73 pub(super) in_flight: bool,
74 pub(super) pending: Option<PendingAction>,
76}
77
78pub(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 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 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 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 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 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 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 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 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 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}