1use crate::components::file_list_panel::{FileListMessage, FileListPanel};
2use crate::components::git_diff::git_diff_panel::{GitDiffPanel, GitDiffPanelMessage};
3use crate::git_diff::{GitDiffDocument, PatchLineKind, load_git_diff};
4use std::path::PathBuf;
5use tui::{Component, Either, Event, Frame, KeyCode, Line, SplitLayout, SplitPanel, Style, ViewContext};
6
7pub enum GitDiffViewMessage {
8 Close,
9 Refresh,
10 SubmitPrompt(String),
11}
12
13pub enum GitDiffLoadState {
14 Loading,
15 Ready(GitDiffDocument),
16 Empty,
17 Error { message: String },
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub struct PatchLineRef {
22 pub hunk_index: usize,
23 pub line_index: usize,
24}
25
26#[derive(Debug, Clone)]
27pub struct QueuedComment {
28 pub file_path: String,
29 pub patch_ref: PatchLineRef,
30 pub line_text: String,
31 pub line_number: Option<usize>,
32 pub line_kind: PatchLineKind,
33 pub comment: String,
34}
35
36pub struct GitDiffMode {
37 working_dir: PathBuf,
38 cached_repo_root: Option<PathBuf>,
39 document_revision: usize,
40 pub load_state: GitDiffLoadState,
41 pub(crate) split: SplitPanel<FileListPanel, GitDiffPanel>,
42 pub(crate) queued_comments: Vec<QueuedComment>,
43 pending_restore: Option<RefreshState>,
44}
45
46impl GitDiffMode {
47 pub fn new(working_dir: PathBuf) -> Self {
48 Self {
49 working_dir,
50 cached_repo_root: None,
51 document_revision: 0,
52 load_state: GitDiffLoadState::Empty,
53 split: SplitPanel::new(FileListPanel::new(), GitDiffPanel::new(), SplitLayout::fraction(1, 3, 20, 28))
54 .with_separator(" ", Style::default())
55 .with_resize_keys(),
56 queued_comments: Vec::new(),
57 pending_restore: None,
58 }
59 }
60
61 pub(crate) fn begin_open(&mut self) {
62 self.reset(GitDiffLoadState::Loading);
63 }
64
65 pub(crate) fn begin_refresh(&mut self) {
66 self.pending_restore = Some(RefreshState {
67 selected_path: self.selected_file_path().map(ToOwned::to_owned),
68 was_right_focused: !self.split.is_left_focused(),
69 });
70 self.load_state = GitDiffLoadState::Loading;
71 self.split.right_mut().invalidate_diff_layer();
72 }
73
74 pub(crate) async fn complete_load(&mut self) {
75 match load_git_diff(&self.working_dir, self.cached_repo_root.as_deref()).await {
76 Ok(doc) => {
77 if self.cached_repo_root.is_none() {
78 self.cached_repo_root = Some(doc.repo_root.clone());
79 }
80 let restore = self.pending_restore.take();
81 self.apply_loaded_document(doc, restore);
82 }
83 Err(error) => {
84 self.pending_restore = None;
85 self.load_state = GitDiffLoadState::Error { message: error.to_string() };
86 self.split.right_mut().invalidate_diff_layer();
87 }
88 }
89 }
90
91 pub(crate) fn close(&mut self) {
92 self.reset(GitDiffLoadState::Empty);
93 }
94
95 fn reset(&mut self, load_state: GitDiffLoadState) {
96 self.pending_restore = None;
97 self.load_state = load_state;
98 *self.split.left_mut() = FileListPanel::new();
99 *self.split.right_mut() = GitDiffPanel::new();
100 self.queued_comments.clear();
101 self.split.focus_left();
102 }
103
104 pub async fn on_key_event(&mut self, event: &Event) -> Vec<GitDiffViewMessage> {
105 if self.split.right().is_in_comment_mode() {
106 return self.on_comment_mode_event(event).await;
107 }
108
109 if let Event::Key(key) = event {
110 match key.code {
111 KeyCode::Esc => return vec![GitDiffViewMessage::Close],
112 KeyCode::Char('r') => return vec![GitDiffViewMessage::Refresh],
113 KeyCode::Char('u') => {
114 self.queued_comments.pop();
115 self.split.left_mut().set_queued_comment_count(self.queued_comments.len());
116 self.split.right_mut().invalidate_submitted_comments_layer();
117 return vec![];
118 }
119 KeyCode::Char('s') if !self.split.is_left_focused() => {
120 return self.submit_review();
121 }
122 KeyCode::Char('h') | KeyCode::Left if !self.split.is_left_focused() => {
123 self.split.focus_left();
124 return vec![];
125 }
126 _ => {}
127 }
128 }
129
130 if let Some(msgs) = self.split.on_event(event).await {
131 return self.handle_split_messages(msgs);
132 }
133
134 vec![]
135 }
136
137 pub fn render_frame(&mut self, context: &ViewContext) -> Frame {
138 let theme = &context.theme;
139 if context.size.width < 10 {
140 return Frame::new(vec![Line::new("Too narrow")]);
141 }
142
143 let status_msg = match &self.load_state {
144 GitDiffLoadState::Loading => Some("Loading...".to_string()),
145 GitDiffLoadState::Empty => Some("No changes in working tree relative to HEAD".to_string()),
146 GitDiffLoadState::Ready(doc) if doc.files.is_empty() => {
147 Some("No changes in working tree relative to HEAD".to_string())
148 }
149 GitDiffLoadState::Error { message } => Some(format!("Git diff unavailable: {message}")),
150 GitDiffLoadState::Ready(_) => None,
151 };
152
153 if let Some(msg) = status_msg {
154 let height = context.size.height as usize;
155 let widths = self.split.widths(context.size.width);
156 let left_width = widths.left as usize;
157 let mut rows = Vec::with_capacity(height);
158 for i in 0..height {
159 let mut line = Line::default();
160 line.push_with_style(" ".repeat(left_width), Style::default().bg_color(theme.sidebar_bg()));
161 line.push_with_style(" ", Style::default().bg_color(theme.background()));
162 if i == 0 {
163 line.push_with_style(&msg, Style::fg(theme.text_secondary()));
164 }
165 rows.push(line);
166 }
167 return Frame::new(rows);
168 }
169
170 self.prepare_right_panel_layers(context);
171 self.split.set_separator_style(Style::default().bg_color(theme.background()));
172 self.split.render(context)
173 }
174
175 fn prepare_right_panel_layers(&mut self, context: &ViewContext) {
176 let GitDiffLoadState::Ready(doc) = &self.load_state else {
177 return;
178 };
179
180 let selected = self.split.left().selected_file_index().unwrap_or(0).min(doc.files.len().saturating_sub(1));
181 let file = &doc.files[selected];
182
183 let file_comments =
184 self.queued_comments.iter().filter(|comment| comment.file_path == file.path).collect::<Vec<_>>();
185
186 let right_width = self.split.widths(context.size.width).right;
187 self.split.right_mut().ensure_layers(file, &file_comments, right_width, self.document_revision);
188 }
189
190 fn on_file_selected(&mut self, idx: usize) {
191 self.split.left_mut().select_file_index(idx);
192 self.split.right_mut().reset_for_new_file();
193 }
194
195 async fn on_comment_mode_event(&mut self, event: &Event) -> Vec<GitDiffViewMessage> {
196 if let Some(msgs) = self.split.right_mut().on_event(event).await {
197 return self.handle_right_panel_messages(msgs);
198 }
199 vec![]
200 }
201
202 fn handle_split_messages(
203 &mut self,
204 msgs: Vec<Either<FileListMessage, GitDiffPanelMessage>>,
205 ) -> Vec<GitDiffViewMessage> {
206 let mut right_msgs = Vec::new();
207 for msg in msgs {
208 match msg {
209 Either::Left(FileListMessage::Selected(idx)) => {
210 self.on_file_selected(idx);
211 }
212 Either::Left(FileListMessage::FileOpened(idx)) => {
213 self.on_file_selected(idx);
214 self.split.focus_right();
215 }
216 Either::Right(panel_msg) => right_msgs.push(panel_msg),
217 }
218 }
219 self.handle_right_panel_messages(right_msgs)
220 }
221
222 fn handle_right_panel_messages(&mut self, msgs: Vec<GitDiffPanelMessage>) -> Vec<GitDiffViewMessage> {
223 for msg in msgs {
224 let GitDiffPanelMessage::CommentSubmitted { anchor, text } = msg;
225 self.queue_comment(anchor, &text);
226 }
227 vec![]
228 }
229
230 fn queue_comment(&mut self, anchor: PatchLineRef, text: &str) {
231 let GitDiffLoadState::Ready(doc) = &self.load_state else {
232 return;
233 };
234 let selected = self.split.left().selected_file_index().unwrap_or(0);
235 let Some(file) = doc.files.get(selected) else {
236 return;
237 };
238 let Some(hunk) = file.hunks.get(anchor.hunk_index) else {
239 return;
240 };
241 let Some(patch_line) = hunk.lines.get(anchor.line_index) else {
242 return;
243 };
244
245 self.queued_comments.push(QueuedComment {
246 file_path: file.path.clone(),
247 patch_ref: anchor,
248 line_text: patch_line.text.clone(),
249 line_number: patch_line.new_line_no.or(patch_line.old_line_no),
250 line_kind: patch_line.kind,
251 comment: text.to_string(),
252 });
253 self.split.left_mut().set_queued_comment_count(self.queued_comments.len());
254 self.split.right_mut().invalidate_submitted_comments_layer();
255 }
256
257 fn submit_review(&self) -> Vec<GitDiffViewMessage> {
258 if self.queued_comments.is_empty() {
259 return vec![];
260 }
261 let prompt = format_review_prompt(&self.queued_comments);
262 vec![GitDiffViewMessage::SubmitPrompt(prompt)]
263 }
264
265 fn selected_file_path(&self) -> Option<&str> {
266 let GitDiffLoadState::Ready(doc) = &self.load_state else {
267 return None;
268 };
269 let idx = self.split.left().selected_file_index()?;
270 doc.files.get(idx).map(|f| f.path.as_str())
271 }
272
273 pub fn load_document(&mut self, doc: GitDiffDocument) {
274 self.apply_loaded_document(doc, None);
275 }
276
277 fn apply_loaded_document(&mut self, doc: GitDiffDocument, restore: Option<RefreshState>) {
278 self.document_revision = self.document_revision.saturating_add(1);
279
280 if doc.files.is_empty() {
281 self.load_state = GitDiffLoadState::Empty;
282 self.split.right_mut().invalidate_diff_layer();
283 return;
284 }
285
286 self.split.left_mut().rebuild_from_files(&doc.files);
287 self.split.right_mut().invalidate_diff_layer();
288
289 if let Some(restore) = restore {
290 if restore.was_right_focused {
291 self.split.focus_right();
292 } else {
293 self.split.focus_left();
294 }
295 self.split.right_mut().reset_scroll();
296 if let Some(path) = &restore.selected_path
297 && let Some(idx) = doc.files.iter().position(|file| file.path == *path)
298 {
299 self.split.left_mut().select_file_index(idx);
300 }
301 }
302
303 self.load_state = GitDiffLoadState::Ready(doc);
304 }
305}
306
307struct RefreshState {
308 selected_path: Option<String>,
309 was_right_focused: bool,
310}
311
312pub(crate) fn format_review_prompt(comments: &[QueuedComment]) -> String {
313 use std::fmt::Write;
314
315 let mut prompt = String::from("I'm reviewing the working tree diff. Here are my comments:\n");
316
317 let mut file_groups: Vec<(&str, Vec<&QueuedComment>)> = Vec::new();
318 for comment in comments {
319 if let Some(group) = file_groups.iter_mut().find(|(path, _)| *path == comment.file_path) {
320 group.1.push(comment);
321 } else {
322 file_groups.push((&comment.file_path, vec![comment]));
323 }
324 }
325
326 for (file_path, file_comments) in &file_groups {
327 write!(prompt, "\n## `{file_path}`\n").unwrap();
328
329 for comment in file_comments {
330 let kind_label = match comment.line_kind {
331 PatchLineKind::Added => "added",
332 PatchLineKind::Removed => "removed",
333 PatchLineKind::Context => "context",
334 PatchLineKind::HunkHeader => "header",
335 PatchLineKind::Meta => "meta",
336 };
337 let line_ref = match comment.line_number {
338 Some(n) => format!("Line {n} ({kind_label})"),
339 None => kind_label.to_string(),
340 };
341 write!(prompt, "\n**{line_ref}:** `{}`\n> {}\n", comment.line_text, comment.comment).unwrap();
342 }
343 }
344
345 prompt
346}