1use crate::components::common::render_key_hints;
2use crate::components::file_list_panel::{FileListMessage, FileListPanel};
3use crate::components::git_diff::git_diff_panel::{GitDiffPanel, GitDiffPanelMessage};
4use crate::components::git_diff::{DiffAnchor, PatchAnchor};
5use crate::components::review_comments::{CommentAnchor, ReviewComment};
6use crate::git_diff::{
7 FileDiff, FileStatus, GitDiffDocument, GitDiffError, PatchLineKind, StageState, commit, load_git_diff, stage_all,
8 stage_file, unstage_all, unstage_file,
9};
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use tui::{
13 Component, Cursor, Either, Event, Frame, KeyCode, Line, SplitLayout, SplitPanel, Style, TextField, ViewContext,
14 display_width_text, truncate_line,
15};
16
17pub enum GitDiffViewMessage {
18 Close,
19 Refresh,
20 SubmitPrompt(String),
21}
22
23pub enum GitDiffLoadState {
24 Loading,
25 Ready(GitDiffDocument),
26 Empty,
27 Error { message: String },
28}
29
30#[derive(Debug, Clone)]
31pub(crate) struct GitDiffCommentContext {
32 pub file_path: String,
33 pub line_text: String,
34 pub line_number: Option<usize>,
35 pub line_kind: PatchLineKind,
36}
37
38#[derive(Debug, Clone)]
39pub(crate) struct QueuedComment {
40 pub review: ReviewComment<PatchAnchor>,
41 pub context: GitDiffCommentContext,
42}
43
44pub struct GitDiffMode {
45 working_dir: PathBuf,
46 cached_repo_root: Option<PathBuf>,
47 document_revision: usize,
48 pub load_state: GitDiffLoadState,
49 split: SplitPanel<FileListPanel, GitDiffPanel>,
50 comments: ReviewQueue,
51 pending_restore: Option<RefreshState>,
52 bottom: BottomBar,
53}
54
55impl GitDiffMode {
56 pub fn new(working_dir: PathBuf) -> Self {
57 Self {
58 working_dir,
59 cached_repo_root: None,
60 document_revision: 0,
61 load_state: GitDiffLoadState::Empty,
62 split: SplitPanel::new(FileListPanel::new(), GitDiffPanel::new(), SplitLayout::fraction(1, 3, 20, 28))
63 .with_separator("│", Style::default())
64 .with_resize_keys(),
65 comments: ReviewQueue::default(),
66 pending_restore: None,
67 bottom: BottomBar::Help,
68 }
69 }
70
71 pub(crate) fn begin_open(&mut self) {
72 self.reset(GitDiffLoadState::Loading);
73 }
74
75 pub(crate) fn begin_refresh(&mut self) {
76 self.pending_restore = Some(RefreshState {
77 selected_path: self.selected_file_path().map(ToOwned::to_owned),
78 was_right_focused: !self.split.is_left_focused(),
79 });
80 self.load_state = GitDiffLoadState::Loading;
81 self.split.right_mut().clear_rendered_patches();
82 }
83
84 pub(crate) async fn complete_load(&mut self) {
85 match load_git_diff(&self.working_dir, self.cached_repo_root.as_deref()).await {
86 Ok(doc) => {
87 if self.cached_repo_root.is_none() {
88 self.cached_repo_root = Some(doc.repo_root.clone());
89 }
90 let restore = self.pending_restore.take();
91 self.apply_loaded_document(doc, restore);
92 }
93 Err(error) => {
94 self.pending_restore = None;
95 self.load_state = GitDiffLoadState::Error { message: error.to_string() };
96 self.split.right_mut().clear_rendered_patches();
97 }
98 }
99 }
100
101 pub(crate) fn close(&mut self) {
102 self.reset(GitDiffLoadState::Empty);
103 }
104
105 fn reset(&mut self, load_state: GitDiffLoadState) {
106 self.pending_restore = None;
107 self.bottom = BottomBar::Help;
108 self.load_state = load_state;
109 *self.split.left_mut() = FileListPanel::new();
110 *self.split.right_mut() = GitDiffPanel::new();
111 self.comments.clear();
112 self.split.focus_left();
113 }
114
115 pub fn load_document(&mut self, doc: GitDiffDocument) {
116 self.apply_loaded_document(doc, None);
117 }
118
119 pub fn set_load_state(&mut self, state: GitDiffLoadState) {
120 self.load_state = state;
121 }
122}
123
124impl Component for GitDiffMode {
125 type Message = GitDiffViewMessage;
126
127 async fn on_event(&mut self, event: &Event) -> Option<Vec<GitDiffViewMessage>> {
128 match &self.bottom {
129 BottomBar::Commit(_) => return Some(self.on_commit_composer_event(event).await),
130 BottomBar::ConfirmDiscard(_) => return Some(self.on_discard_confirm_event(event).await),
131 BottomBar::Error(_) if matches!(event, Event::Key(_)) => self.bottom = BottomBar::Help,
132 _ => {}
133 }
134
135 if self.split.right().is_in_comment_mode() {
136 return Some(self.on_comment_mode_event(event).await);
137 }
138
139 if let Event::Key(key) = event {
140 match key.code {
141 KeyCode::Esc => return Some(vec![GitDiffViewMessage::Close]),
142 KeyCode::Char(' ') => return Some(self.toggle_stage_selected().await),
143 KeyCode::Char('a') => return Some(self.stage_all().await),
144 KeyCode::Char('A') => return Some(self.unstage_all().await),
145 KeyCode::Char('C') => return Some(self.begin_commit()),
146 KeyCode::Char('d') => return Some(self.request_discard()),
147 KeyCode::Char('r') => return Some(vec![GitDiffViewMessage::Refresh]),
148 KeyCode::Char('u') => {
149 self.comments.pop();
150 self.sync_comment_state();
151 self.split.right_mut().invalidate_comment_splices();
152 return Some(vec![]);
153 }
154 KeyCode::Char('s') if !self.split.is_left_focused() => {
155 return Some(self.submit_review());
156 }
157 KeyCode::Char('h') | KeyCode::Left if !self.split.is_left_focused() => {
158 self.split.focus_left();
159 return Some(vec![]);
160 }
161 _ => {}
162 }
163 }
164
165 self.split.on_event(event).await.map(|msgs| self.handle_split_messages(msgs))
166 }
167
168 fn render(&mut self, context: &ViewContext) -> Frame {
169 let theme = &context.theme;
170 if context.size.width < 10 {
171 return Frame::new(vec![Line::new("Too narrow")]);
172 }
173
174 let bottom = self.render_bottom_bar(theme, context.size.width as usize);
175 let bottom_height = u16::try_from(bottom.lines().len()).unwrap_or(1).max(1);
176 let body_height = context.size.height.saturating_sub(bottom_height);
177 let body_context = context.with_size((context.size.width, body_height));
178
179 let status_msg = match &self.load_state {
180 GitDiffLoadState::Loading => Some("Loading...".to_string()),
181 GitDiffLoadState::Empty => Some("No changes in working tree relative to HEAD".to_string()),
182 GitDiffLoadState::Ready(doc) if doc.files.is_empty() => {
183 Some("No changes in working tree relative to HEAD".to_string())
184 }
185 GitDiffLoadState::Error { message } => Some(format!("Git diff unavailable: {message}")),
186 GitDiffLoadState::Ready(_) => None,
187 };
188
189 let body = if let Some(msg) = status_msg {
190 let height = body_height as usize;
191 let widths = self.split.widths(context.size.width);
192 let left_width = widths.left as usize;
193 let mut rows = Vec::with_capacity(height);
194 for i in 0..height {
195 let mut line = Line::default();
196 line.push_text(" ".repeat(left_width));
197 line.push_with_style("│", Style::fg(theme.muted()));
198 if i == 0 {
199 line.push_with_style(&msg, Style::fg(theme.text_secondary()));
200 }
201 rows.push(line);
202 }
203 Frame::new(rows)
204 } else {
205 let left_focused = self.split.is_left_focused();
206 self.split.left_mut().set_focused(left_focused);
207 self.split.right_mut().set_focused(!left_focused);
208 self.prepare_right_panel_layers(&body_context);
209 self.split.set_separator_style(Style::fg(theme.muted()));
210 self.split.render(&body_context)
211 };
212
213 Frame::vstack([body, bottom])
214 }
215}
216
217impl GitDiffMode {
218 fn prepare_right_panel_layers(&mut self, context: &ViewContext) {
219 let GitDiffLoadState::Ready(doc) = &self.load_state else {
220 return;
221 };
222
223 let selected = self.split.left().selected_file_index().unwrap_or(0).min(doc.files.len().saturating_sub(1));
224 let file = &doc.files[selected];
225
226 let file_comments = self.comments.for_file(&file.path);
227
228 let right_width = self.split.widths(context.size.width).right;
229 self.split.right_mut().ensure_layers(file, &file_comments, right_width, self.document_revision);
230 }
231
232 fn on_file_selected(&mut self, idx: usize) {
233 self.split.left_mut().select_file_index(idx);
234 self.split.right_mut().reset_for_new_file();
235 }
236
237 async fn on_comment_mode_event(&mut self, event: &Event) -> Vec<GitDiffViewMessage> {
238 if let Some(msgs) = self.split.right_mut().on_event(event).await {
239 return self.handle_right_panel_messages(msgs);
240 }
241 vec![]
242 }
243
244 fn handle_split_messages(
245 &mut self,
246 msgs: Vec<Either<FileListMessage, GitDiffPanelMessage>>,
247 ) -> Vec<GitDiffViewMessage> {
248 let mut right_msgs = Vec::new();
249 for msg in msgs {
250 match msg {
251 Either::Left(FileListMessage::Selected(idx)) => {
252 self.on_file_selected(idx);
253 }
254 Either::Left(FileListMessage::FileOpened(idx)) => {
255 self.on_file_selected(idx);
256 self.split.focus_right();
257 }
258 Either::Right(panel_msg) => right_msgs.push(panel_msg),
259 }
260 }
261 self.handle_right_panel_messages(right_msgs)
262 }
263
264 fn handle_right_panel_messages(&mut self, msgs: Vec<GitDiffPanelMessage>) -> Vec<GitDiffViewMessage> {
265 for msg in msgs {
266 let GitDiffPanelMessage::CommentSubmitted { anchor, text } = msg;
267 self.queue_comment(anchor, &text);
268 }
269 vec![]
270 }
271
272 fn queue_comment(&mut self, anchor: DiffAnchor, text: &str) {
273 let GitDiffLoadState::Ready(doc) = &self.load_state else {
274 return;
275 };
276 let CommentAnchor(PatchAnchor { hunk: hunk_index, line: line_index }) = anchor;
277 let selected = self.split.left().selected_file_index().unwrap_or(0);
278 let Some(file) = doc.files.get(selected) else {
279 return;
280 };
281 let Some(hunk) = file.hunks.get(hunk_index) else {
282 return;
283 };
284 let Some(patch_line) = hunk.lines.get(line_index) else {
285 return;
286 };
287
288 self.comments.push(QueuedComment {
289 review: ReviewComment::new(anchor, text),
290 context: GitDiffCommentContext {
291 file_path: file.path.clone(),
292 line_text: patch_line.text.clone(),
293 line_number: patch_line.new_line_no.or(patch_line.old_line_no),
294 line_kind: patch_line.kind,
295 },
296 });
297 self.sync_comment_state();
298 self.split.right_mut().invalidate_comment_splices();
299 }
300
301 fn sync_comment_state(&mut self) {
302 let counts = match &self.load_state {
303 GitDiffLoadState::Ready(doc) => self.comments.counts_for(&doc.files),
304 _ => vec![],
305 };
306 self.split.left_mut().sync_view_state(self.comments.len(), counts);
307 }
308
309 fn submit_review(&self) -> Vec<GitDiffViewMessage> {
310 if self.comments.is_empty() {
311 return vec![];
312 }
313 vec![GitDiffViewMessage::SubmitPrompt(self.comments.format_prompt())]
314 }
315
316 fn selected_file_path(&self) -> Option<&str> {
317 let GitDiffLoadState::Ready(doc) = &self.load_state else {
318 return None;
319 };
320 let idx = self.split.left().selected_file_index()?;
321 doc.files.get(idx).map(|f| f.path.as_str())
322 }
323
324 fn repo_root(&self) -> Option<&Path> {
325 match &self.load_state {
326 GitDiffLoadState::Ready(doc) => Some(&doc.repo_root),
327 _ => self.cached_repo_root.as_deref(),
328 }
329 }
330
331 fn repo_root_buf(&self) -> Option<PathBuf> {
332 self.repo_root().map(Path::to_path_buf)
333 }
334
335 fn selected_file(&self) -> Option<&FileDiff> {
336 let GitDiffLoadState::Ready(doc) = &self.load_state else {
337 return None;
338 };
339 let idx = self.split.left().selected_file_index()?;
340 doc.files.get(idx)
341 }
342
343 fn any_staged(&self) -> bool {
344 matches!(&self.load_state, GitDiffLoadState::Ready(doc)
345 if doc.files.iter().any(|file| matches!(file.staged, StageState::Staged | StageState::PartiallyStaged)))
346 }
347
348 async fn toggle_stage_selected(&mut self) -> Vec<GitDiffViewMessage> {
349 let Some(file) = self.selected_file() else {
350 return vec![];
351 };
352 let (path, staged) = (file.path.clone(), file.staged);
353 let Some(root) = self.repo_root_buf() else {
354 return vec![];
355 };
356 let result = match staged {
357 StageState::Staged => unstage_file(&root, &path).await,
358 StageState::Unstaged | StageState::PartiallyStaged => stage_file(&root, &path).await,
359 };
360 self.apply_action_result(result)
361 }
362
363 async fn stage_all(&mut self) -> Vec<GitDiffViewMessage> {
364 let Some(root) = self.repo_root_buf() else {
365 return vec![];
366 };
367 let result = stage_all(&root).await;
368 self.apply_action_result(result)
369 }
370
371 async fn unstage_all(&mut self) -> Vec<GitDiffViewMessage> {
372 let Some(root) = self.repo_root_buf() else {
373 return vec![];
374 };
375 let result = unstage_all(&root).await;
376 self.apply_action_result(result)
377 }
378
379 fn apply_action_result(&mut self, result: Result<(), GitDiffError>) -> Vec<GitDiffViewMessage> {
380 match result {
381 Ok(()) => vec![GitDiffViewMessage::Refresh],
382 Err(error) => {
383 self.bottom = BottomBar::Error(error.to_string());
384 vec![]
385 }
386 }
387 }
388
389 fn request_discard(&mut self) -> Vec<GitDiffViewMessage> {
390 let Some(file) = self.selected_file() else {
391 return vec![];
392 };
393 self.bottom = BottomBar::ConfirmDiscard(PendingDiscard { path: file.path.clone(), status: file.status });
394 vec![]
395 }
396
397 fn begin_commit(&mut self) -> Vec<GitDiffViewMessage> {
398 if !self.any_staged() {
399 self.bottom = BottomBar::Error("Nothing staged to commit".to_string());
400 return vec![];
401 }
402 self.bottom = BottomBar::Commit(TextField::new(String::new()));
403 vec![]
404 }
405
406 async fn on_commit_composer_event(&mut self, event: &Event) -> Vec<GitDiffViewMessage> {
407 if let Event::Key(key) = event {
408 match key.code {
409 KeyCode::Esc => {
410 self.bottom = BottomBar::Help;
411 return vec![];
412 }
413 KeyCode::Enter => {
414 let message = match &self.bottom {
415 BottomBar::Commit(field) => field.value.trim().to_string(),
416 _ => String::new(),
417 };
418 if message.is_empty() {
419 self.bottom = BottomBar::Error("Commit message cannot be empty".to_string());
420 return vec![];
421 }
422 self.bottom = BottomBar::Help;
423 let Some(root) = self.repo_root_buf() else {
424 return vec![];
425 };
426 let result = commit(&root, &message).await;
427 return self.apply_action_result(result);
428 }
429 _ => {}
430 }
431 }
432 if let BottomBar::Commit(field) = &mut self.bottom {
433 field.on_event(event).await;
434 }
435 vec![]
436 }
437
438 async fn on_discard_confirm_event(&mut self, event: &Event) -> Vec<GitDiffViewMessage> {
439 let Event::Key(key) = event else {
440 return vec![];
441 };
442 match key.code {
443 KeyCode::Char('y' | 'Y') => {
444 let BottomBar::ConfirmDiscard(PendingDiscard { path, status }) =
445 std::mem::replace(&mut self.bottom, BottomBar::Help)
446 else {
447 return vec![];
448 };
449 let Some(root) = self.repo_root_buf() else {
450 return vec![];
451 };
452 let result = crate::git_diff::discard_file(&root, &path, status).await;
453 self.apply_action_result(result)
454 }
455 KeyCode::Char('n' | 'N') | KeyCode::Esc => {
456 self.bottom = BottomBar::Help;
457 vec![]
458 }
459 _ => vec![],
460 }
461 }
462
463 fn render_bottom_bar(&self, theme: &tui::Theme, width: usize) -> Frame {
464 match &self.bottom {
465 BottomBar::Commit(field) => {
466 let prefix_width = display_width_text("commit › ");
467 let avail = width.saturating_sub(prefix_width + 1).max(1);
468 let (visible, cursor_col) = field.single_line_window(avail);
469
470 let mut input = Line::default();
471 input.push_with_style("commit", Style::fg(theme.accent()).bold());
472 input.push_with_style(" › ", Style::fg(theme.muted()));
473 input.push_with_style(&visible, Style::fg(theme.text_primary()));
474 let hint = truncate_line(&render_key_hints(theme, &COMMIT_HELP_KEYS), width);
475 Frame::new(vec![input, hint]).with_cursor(Cursor::visible(0, prefix_width + cursor_col))
476 }
477 BottomBar::ConfirmDiscard(pending) => {
478 let mut line = Line::default();
479 line.push_with_style("Discard changes to ", Style::fg(theme.warning()));
480 line.push_with_style(&pending.path, Style::fg(theme.warning()).bold());
481 line.push_with_style("? ", Style::fg(theme.warning()));
482 line.push_with_style("y", Style::fg(theme.accent()));
483 line.push_with_style(" confirm ", Style::fg(theme.muted()));
484 line.push_with_style("n", Style::fg(theme.accent()));
485 line.push_with_style(" cancel", Style::fg(theme.muted()));
486 Frame::new(vec![truncate_line(&line, width)])
487 }
488 BottomBar::Error(error) => {
489 let mut line = Line::default();
490 line.push_with_style(error, Style::fg(theme.warning()));
491 Frame::new(vec![truncate_line(&line, width)])
492 }
493 BottomBar::Help => {
494 let help_keys: &[(&str, &str)] =
495 if self.split.is_left_focused() { &LEFT_HELP_KEYS } else { &RIGHT_HELP_KEYS };
496 Frame::new(vec![truncate_line(&render_key_hints(theme, help_keys), width)])
497 }
498 }
499 }
500
501 fn apply_loaded_document(&mut self, doc: GitDiffDocument, restore: Option<RefreshState>) {
502 self.document_revision = self.document_revision.saturating_add(1);
503
504 if doc.files.is_empty() {
505 self.load_state = GitDiffLoadState::Empty;
506 self.split.right_mut().clear_rendered_patches();
507 return;
508 }
509
510 self.split.left_mut().rebuild_from_files(&doc.files);
511 self.split.right_mut().clear_rendered_patches();
512 self.split.right_mut().set_repo_root(doc.repo_root.clone());
513
514 if let Some(restore) = restore {
515 if restore.was_right_focused {
516 self.split.focus_right();
517 } else {
518 self.split.focus_left();
519 }
520 self.split.right_mut().reset_scroll();
521 if let Some(path) = &restore.selected_path
522 && let Some(idx) = doc.files.iter().position(|file| file.path == *path)
523 {
524 self.split.left_mut().select_file_index(idx);
525 }
526 }
527
528 self.load_state = GitDiffLoadState::Ready(doc);
529 self.sync_comment_state();
530 }
531}
532
533const LEFT_HELP_KEYS: [(&str, &str); 6] =
534 [("j/k", "move"), ("space", "stage"), ("A", "stage all"), ("C", "commit"), ("d", "discard"), ("Esc", "close")];
535
536const COMMIT_HELP_KEYS: [(&str, &str); 2] = [("enter", "commit"), ("esc", "cancel")];
537
538const RIGHT_HELP_KEYS: [(&str, &str); 7] = [
539 ("space", "stage"),
540 ("c", "comment"),
541 ("C", "commit"),
542 ("d", "discard"),
543 ("s", "submit"),
544 ("o", "full file"),
545 ("Esc", "close"),
546];
547
548enum BottomBar {
549 Help,
550 Commit(TextField),
551 ConfirmDiscard(PendingDiscard),
552 Error(String),
553}
554
555struct PendingDiscard {
556 path: String,
557 status: FileStatus,
558}
559
560#[derive(Default)]
561struct ReviewQueue {
562 comments: Vec<QueuedComment>,
563}
564
565impl ReviewQueue {
566 fn is_empty(&self) -> bool {
567 self.comments.is_empty()
568 }
569
570 fn len(&self) -> usize {
571 self.comments.len()
572 }
573
574 fn clear(&mut self) {
575 self.comments.clear();
576 }
577
578 fn push(&mut self, comment: QueuedComment) {
579 self.comments.push(comment);
580 }
581
582 fn pop(&mut self) -> Option<QueuedComment> {
583 self.comments.pop()
584 }
585
586 fn for_file(&self, path: &str) -> Vec<&QueuedComment> {
587 self.comments.iter().filter(|comment| comment.context.file_path == path).collect()
588 }
589
590 fn counts_for(&self, files: &[crate::git_diff::FileDiff]) -> Vec<usize> {
591 files
592 .iter()
593 .map(|file| self.comments.iter().filter(|comment| comment.context.file_path == file.path).count())
594 .collect()
595 }
596
597 fn format_prompt(&self) -> String {
598 use std::fmt::Write;
599
600 let mut prompt = String::from("I'm reviewing the working tree diff. Here are my comments:\n");
601 let mut file_order: Vec<&str> = Vec::new();
602 let mut grouped: HashMap<&str, Vec<&QueuedComment>> = HashMap::new();
603
604 for comment in &self.comments {
605 let path = comment.context.file_path.as_str();
606 if !grouped.contains_key(path) {
607 file_order.push(path);
608 }
609 grouped.entry(path).or_default().push(comment);
610 }
611
612 for file_path in file_order {
613 let file_comments = grouped.get(file_path).expect("group exists for ordered path");
614 write!(prompt, "\n## `{file_path}`\n").unwrap();
615
616 for comment in file_comments {
617 let kind_label = match comment.context.line_kind {
618 PatchLineKind::Added => "added",
619 PatchLineKind::Removed => "removed",
620 PatchLineKind::Context => "context",
621 PatchLineKind::HunkHeader => "header",
622 PatchLineKind::Meta => "meta",
623 };
624 let line_ref = match comment.context.line_number {
625 Some(n) => format!("Line {n} ({kind_label})"),
626 None => kind_label.to_string(),
627 };
628 write!(prompt, "\n**{line_ref}:** `{}`\n> {}\n", comment.context.line_text, comment.review.body)
629 .unwrap();
630 }
631 }
632
633 prompt
634 }
635}
636
637struct RefreshState {
638 selected_path: Option<String>,
639 was_right_focused: bool,
640}