use std::path::PathBuf;
use travelagent_core::model::{Comment, CommentType, LineRange, LineSide};
use super::{AnnotatedLine, App, ConfirmAction, InputMode};
enum CommentLocation {
Review {
index: usize,
},
File {
path: std::path::PathBuf,
index: usize,
},
Line {
path: std::path::PathBuf,
line: u32,
side: LineSide,
index: usize,
},
}
impl App {
fn find_comment_at_cursor(&self) -> Option<CommentLocation> {
let target = self.diff_state.cursor_line;
match self.line_annotations.get(target) {
Some(AnnotatedLine::ReviewComment { comment_idx }) => Some(CommentLocation::Review {
index: *comment_idx,
}),
Some(AnnotatedLine::FileComment {
file_idx,
comment_idx,
}) => {
let path = self.diff_files.get(*file_idx)?.display_path_lossy().clone();
Some(CommentLocation::File {
path,
index: *comment_idx,
})
}
Some(AnnotatedLine::LineComment {
file_idx,
line,
side,
comment_idx,
}) => {
let path = self.diff_files.get(*file_idx)?.display_path_lossy().clone();
Some(CommentLocation::Line {
path,
line: *line,
side: *side,
index: *comment_idx,
})
}
_ => None,
}
}
pub fn delete_comment_at_cursor(&mut self) -> bool {
let location = self.find_comment_at_cursor();
let resolved: Option<(String, String)> = match location {
Some(CommentLocation::Review { index }) => self
.engine
.session()
.review_comments
.get(index)
.map(|c| (c.id.clone(), "Review comment deleted".to_string())),
Some(CommentLocation::File { path, index }) => self
.engine
.session()
.files
.get(&path)
.and_then(|r| r.file_comments.get(index))
.map(|c| (c.id.clone(), "Comment deleted".to_string())),
Some(CommentLocation::Line {
path,
line,
side,
index,
}) => self
.engine
.session()
.files
.get(&path)
.and_then(|r| r.line_comments.get(&line))
.and_then(|comments| {
let mut side_idx = 0;
for comment in comments {
let comment_side = comment.side.unwrap_or(LineSide::New);
if comment_side == side {
if side_idx == index {
return Some((
comment.id.clone(),
format!("Comment on line {line} deleted"),
));
}
side_idx += 1;
}
}
None
}),
None => None,
};
let Some((id, message)) = resolved else {
return false;
};
if self.engine.remove_comment(&id) {
self.dirty = true;
self.set_message(message);
self.rebuild_annotations();
true
} else {
false
}
}
pub fn clear_all_comments(&mut self) {
let (cleared, unreviewed) = self.engine.clear_comments();
if cleared == 0 && unreviewed == 0 {
self.set_message("No comments to clear");
return;
}
self.dirty = true;
self.rebuild_annotations();
let msg = match (cleared, unreviewed) {
(0, n) => format!("Unreviewed {n} files"),
(c, 0) => format!("Cleared {c} comments"),
(c, n) => format!("Cleared {c} comments, unreviewed {n} files"),
};
self.set_message(msg);
}
pub fn enter_edit_mode(&mut self) -> bool {
let location = self.find_comment_at_cursor();
match location {
Some(CommentLocation::Review { index }) => {
if let Some(comment) = self.engine.session().review_comments.get(index) {
self.nav.input_mode = InputMode::Comment;
self.comment.buffer = comment.content.clone();
self.comment.cursor = self.comment.buffer.len();
self.comment.comment_type = comment.comment_type.clone();
self.comment.is_review_level = true;
self.comment.is_file_level = false;
self.comment.line = None;
self.comment.editing_id = Some(comment.id.clone());
return true;
}
}
Some(CommentLocation::File { path, index }) => {
if let Some(review) = self.engine.session().files.get(&path)
&& let Some(comment) = review.file_comments.get(index)
{
self.nav.input_mode = InputMode::Comment;
self.comment.buffer = comment.content.clone();
self.comment.cursor = self.comment.buffer.len();
self.comment.comment_type = comment.comment_type.clone();
self.comment.is_review_level = false;
self.comment.is_file_level = true;
self.comment.line = None;
self.comment.editing_id = Some(comment.id.clone());
return true;
}
}
Some(CommentLocation::Line {
path,
line,
side,
index,
}) => {
if let Some(review) = self.engine.session().files.get(&path)
&& let Some(comments) = review.line_comments.get(&line)
{
let mut side_idx = 0;
for comment in comments {
let comment_side = comment.side.unwrap_or(LineSide::New);
if comment_side == side {
if side_idx == index {
self.nav.input_mode = InputMode::Comment;
self.comment.buffer = comment.content.clone();
self.comment.cursor = self.comment.buffer.len();
self.comment.comment_type = comment.comment_type.clone();
self.comment.is_review_level = false;
self.comment.is_file_level = false;
self.comment.line = Some((line, side));
self.comment.editing_id = Some(comment.id.clone());
return true;
}
side_idx += 1;
}
}
}
}
None => {}
}
false
}
pub fn enter_command_mode(&mut self) {
self.nav.command_origin = self.nav.input_mode;
if self.ui_layout.command_palette {
self.nav.input_mode = InputMode::CommandPalette;
} else {
self.nav.input_mode = InputMode::Command;
}
self.palette.clear();
}
pub fn exit_command_mode(&mut self) {
let restore = match self.nav.command_origin {
InputMode::CommitSelect => InputMode::CommitSelect,
_ => InputMode::Normal,
};
self.nav.input_mode = restore;
self.nav.command_origin = InputMode::Normal;
self.palette.clear();
}
pub fn enter_search_mode(&mut self) {
self.nav.input_mode = InputMode::Search;
self.search_buffer.clear();
}
pub fn exit_search_mode(&mut self) {
self.nav.input_mode = InputMode::Normal;
self.search_buffer.clear();
}
pub fn enter_comment_mode(&mut self, file_level: bool, line: Option<(u32, LineSide)>) {
self.nav.input_mode = InputMode::Comment;
self.comment.buffer.clear();
self.comment.cursor = 0;
self.comment.comment_type = self.default_comment_type();
self.comment.is_review_level = false;
self.comment.is_file_level = file_level;
self.comment.line = line;
}
pub fn enter_review_comment_mode(&mut self) {
self.nav.input_mode = InputMode::Comment;
self.comment.buffer.clear();
self.comment.cursor = 0;
self.comment.comment_type = self.default_comment_type();
self.comment.is_review_level = true;
self.comment.is_file_level = false;
self.comment.line = None;
self.comment.line_range = None;
self.comment.editing_id = None;
}
pub fn exit_comment_mode(&mut self) {
self.nav.input_mode = InputMode::Normal;
self.comment.buffer.clear();
self.comment.cursor = 0;
self.comment.is_review_level = false;
self.comment.editing_id = None;
self.comment.line_range = None;
}
pub fn enter_visual_mode(&mut self, line: u32, side: LineSide) {
self.nav.input_mode = InputMode::VisualSelect;
self.comment.visual_anchor = Some((line, side));
}
pub fn exit_visual_mode(&mut self) {
self.nav.input_mode = InputMode::Normal;
self.comment.visual_anchor = None;
}
pub fn get_visual_selection(&self) -> Option<(LineRange, LineSide)> {
if self.nav.input_mode != InputMode::VisualSelect {
return None;
}
let (anchor_line, anchor_side) = self.comment.visual_anchor?;
let (current_line, current_side) = self.get_line_at_cursor()?;
if anchor_side != current_side {
return None;
}
let range = LineRange::new(anchor_line, current_line);
Some((range, anchor_side))
}
pub fn is_line_in_visual_selection(&self, line: u32, side: LineSide) -> bool {
if let Some((range, sel_side)) = self.get_visual_selection() {
sel_side == side && range.contains(line)
} else {
false
}
}
pub fn enter_comment_from_visual(&mut self) {
if let Some((range, side)) = self.get_visual_selection() {
self.comment.line_range = Some((range, side));
self.comment.line = Some((range.end, side)); self.nav.input_mode = InputMode::Comment;
self.comment.buffer.clear();
self.comment.cursor = 0;
self.comment.comment_type = self.default_comment_type();
self.comment.is_review_level = false;
self.comment.is_file_level = false;
self.comment.visual_anchor = None;
} else {
self.set_warning("Invalid visual selection");
self.exit_visual_mode();
}
}
pub fn save_comment(&mut self) {
if self.comment.buffer.trim().is_empty() {
self.set_message("Comment cannot be empty");
return;
}
let content = self.comment.buffer.trim().to_string();
let mut message = "Error: Could not save comment".to_string();
if let Some(editing_id) = self.comment.editing_id.clone() {
if self.engine.edit_comment(
&editing_id,
content.clone(),
self.comment.comment_type.clone(),
) {
message = if self.comment.is_review_level {
"Review comment updated".to_string()
} else if let Some((line, _)) = self.comment.line {
format!("Comment on line {line} updated")
} else {
"Comment updated".to_string()
};
} else {
message = "Error: Comment to edit not found".to_string();
}
} else if self.comment.is_review_level {
let comment = Comment::new(content, self.comment.comment_type.clone(), None);
self.engine.add_review_comment(comment);
message = "Review comment added".to_string();
} else if let Some(path) = self.current_file_path().cloned()
&& self.engine.session().files.contains_key(&path)
{
if self.comment.is_file_level {
let comment = Comment::new(content, self.comment.comment_type.clone(), None);
self.engine.try_add_file_comment(&path, comment);
message = "File comment added".to_string();
} else if let Some((range, side)) = self.comment.line_range {
let comment = Comment::new_with_range(
content,
self.comment.comment_type.clone(),
Some(side),
range,
);
self.engine.try_add_line_comment(&path, range.end, comment);
if range.is_single() {
message = format!("Comment added to line {}", range.end);
} else {
message = format!("Comment added to lines {}-{}", range.start, range.end);
}
} else if let Some((line, side)) = self.comment.line {
let comment = Comment::new(content, self.comment.comment_type.clone(), Some(side));
self.engine.try_add_line_comment(&path, line, comment);
message = format!("Comment added to line {line}");
} else {
let comment = Comment::new(content, self.comment.comment_type.clone(), None);
self.engine.try_add_file_comment(&path, comment);
message = "File comment added".to_string();
}
}
let succeeded = !message.starts_with("Error:");
if succeeded {
self.dirty = true;
if self.comment.editing_id.is_none()
&& let Some(path) = self.current_file_path()
{
let file = path.to_string_lossy().to_string();
let line = self
.comment
.line
.map(|(l, _)| l)
.or(self.comment.line_range.map(|(r, _)| r.end));
self.push_notify(super::McpNotify::CommentAdded {
file,
line,
author: "human",
});
}
}
if self.has_forge()
&& self.comment.editing_id.is_none()
&& !self.comment.is_file_level
&& !self.comment.is_review_level
&& let Some((line, side)) = self.comment.line
&& let Some(file) = self.diff_files.get(self.diff_state.current_file_idx)
{
let path = file.display_path_lossy().to_string_lossy().to_string();
let body = self.comment.buffer.trim().to_string();
match self.post_remote_comment(&path, line, side, &body) {
Ok(Some(remote_id)) => {
if let Some(review) = self
.engine
.session_mut()
.get_file_mut(&PathBuf::from(&path))
&& let Some(comments) = review.line_comments.get_mut(&line)
&& let Some(last) = comments.last_mut()
{
last.remote_id = Some(remote_id);
}
message = format!("{message} (posted to remote)");
}
Ok(None) => {}
Err(e) => {
message = format!("{message} (failed to post to remote: {e})");
}
}
}
self.set_message(message);
self.rebuild_annotations();
self.exit_comment_mode();
}
pub fn cycle_comment_type(&mut self) {
if self.comment.types.is_empty() {
return;
}
let current_id = self.comment.comment_type.id();
let current_index = self
.comment
.types
.iter()
.position(|comment_type| comment_type.id == current_id)
.unwrap_or(0);
let next_index = (current_index + 1) % self.comment.types.len();
self.comment.comment_type = CommentType::from_id(&self.comment.types[next_index].id);
}
pub fn cycle_comment_type_reverse(&mut self) {
if self.comment.types.is_empty() {
return;
}
let current_id = self.comment.comment_type.id();
let current_index = self
.comment
.types
.iter()
.position(|comment_type| comment_type.id == current_id)
.unwrap_or(0);
let prev_index = if current_index == 0 {
self.comment.types.len() - 1
} else {
current_index - 1
};
self.comment.comment_type = CommentType::from_id(&self.comment.types[prev_index].id);
}
pub fn toggle_help(&mut self) {
if self.nav.input_mode == InputMode::Help {
self.nav.input_mode = InputMode::Normal;
} else {
self.nav.input_mode = InputMode::Help;
self.help_state.scroll_offset = 0;
}
}
pub fn help_scroll_down(&mut self, lines: usize) {
let max_offset = self
.help_state
.total_lines
.saturating_sub(self.help_state.viewport_height);
self.help_state.scroll_offset = (self.help_state.scroll_offset + lines).min(max_offset);
}
pub fn help_scroll_up(&mut self, lines: usize) {
self.help_state.scroll_offset = self.help_state.scroll_offset.saturating_sub(lines);
}
pub fn help_scroll_to_top(&mut self) {
self.help_state.scroll_offset = 0;
}
pub fn help_scroll_to_bottom(&mut self) {
let max_offset = self
.help_state
.total_lines
.saturating_sub(self.help_state.viewport_height);
self.help_state.scroll_offset = max_offset;
}
pub fn enter_confirm_mode(&mut self, action: ConfirmAction) {
self.nav.input_mode = InputMode::Confirm;
self.pending_confirm = Some(action);
}
pub fn exit_confirm_mode(&mut self) {
self.nav.input_mode = InputMode::Normal;
self.pending_confirm = None;
}
pub fn enter_reaction_picker(&mut self, thread_id: String) {
self.nav.input_mode = InputMode::ReactionPicker;
self.reaction_picker_cursor = 0;
if let Some(r) = self.remote_mut() {
r.reaction_picker_target_thread = Some(thread_id);
}
}
pub fn enter_comment_template_picker(&mut self) {
if self.comment_templates.is_empty() {
self.set_warning(
"No comment templates configured — see ~/.config/travelagent/config.toml",
);
return;
}
self.nav.input_mode = InputMode::CommentTemplatePicker;
self.ui_layout.reset_template_picker();
}
pub fn exit_comment_template_picker(&mut self) {
self.nav.input_mode = InputMode::Comment;
self.ui_layout.reset_template_picker();
}
pub fn select_comment_template(&mut self) {
use crate::ui::comment_template_picker::filter_templates;
let filtered = filter_templates(&self.comment_templates, self.ui_layout.template_filter());
let Some(&entry_idx) = filtered.get(self.ui_layout.template_cursor()) else {
self.exit_comment_template_picker();
return;
};
let body = self.comment_templates[entry_idx].1.clone();
let cursor = self.comment.cursor.min(self.comment.buffer.len());
self.comment.buffer.insert_str(cursor, &body);
self.comment.cursor = cursor + body.len();
self.exit_comment_template_picker();
}
pub fn exit_reaction_picker(&mut self) {
self.nav.input_mode = InputMode::Normal;
self.reaction_picker_cursor = 0;
if let Some(r) = self.remote_mut() {
r.reaction_picker_target_thread = None;
}
}
}
#[cfg(test)]
mod tests {
use crate::app::{App, InputMode};
use crate::test_support::cwd_lock;
use crate::theme::Theme;
use tempfile::TempDir;
fn build_test_app() -> (App, TempDir) {
let _lock = cwd_lock();
let dir = TempDir::new().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.unwrap();
std::fs::write(dir.path().join("test.txt"), "hello\n").unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let theme = Theme::dark();
let app = App::new(
theme,
None,
false,
None,
true,
None,
None,
crate::test_support::runtime_handle(),
)
.unwrap();
std::env::set_current_dir(original_dir).unwrap();
(app, dir)
}
#[test]
fn enter_reaction_picker_sets_mode_and_target() {
let (mut app, _dir) = build_test_app();
app.reaction_picker_cursor = 5;
app.enter_reaction_picker("thread-abc".to_string());
assert_eq!(app.nav.input_mode, InputMode::ReactionPicker);
assert!(app.remote().is_none());
assert_eq!(app.reaction_picker_cursor, 0);
}
#[test]
fn exit_reaction_picker_resets_mode() {
let (mut app, _dir) = build_test_app();
app.enter_reaction_picker("thread-xyz".to_string());
app.reaction_picker_cursor = 3;
app.exit_reaction_picker();
assert_eq!(app.nav.input_mode, InputMode::Normal);
assert!(app.remote().is_none());
assert_eq!(app.reaction_picker_cursor, 0);
}
#[test]
fn open_picker_with_no_templates_shows_friendly_error_and_does_not_open_popup() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::Comment;
assert!(app.comment_templates.is_empty());
app.enter_comment_template_picker();
assert_eq!(app.nav.input_mode, InputMode::Comment);
let msg = app.message.as_ref().expect("warning message should be set");
assert!(
msg.content.contains("No comment templates configured"),
"expected friendly warning, got {:?}",
msg.content
);
assert!(
msg.content.contains("config.toml"),
"warning should mention the config file path"
);
}
#[test]
fn open_picker_with_templates_shows_all_sorted_alphabetically() {
let (mut app, _dir) = build_test_app();
let mut map = std::collections::HashMap::new();
map.insert("style".to_string(), "Nit (style): ".to_string());
map.insert("nit".to_string(), "nit: ".to_string());
map.insert("q".to_string(), "Question: ".to_string());
app.set_comment_templates(Some(map));
let names: Vec<&str> = app
.comment_templates
.iter()
.map(|(n, _)| n.as_str())
.collect();
assert_eq!(names, vec!["nit", "q", "style"]);
app.nav.input_mode = InputMode::Comment;
app.enter_comment_template_picker();
assert_eq!(app.nav.input_mode, InputMode::CommentTemplatePicker);
assert_eq!(app.ui_layout.template_cursor(), 0);
assert!(app.ui_layout.template_filter().is_empty());
}
#[test]
fn selecting_template_inserts_text_at_cursor_position() {
let (mut app, _dir) = build_test_app();
let mut map = std::collections::HashMap::new();
map.insert("nit".to_string(), "nit: ".to_string());
map.insert("q".to_string(), "Question: ".to_string());
app.set_comment_templates(Some(map));
app.comment.buffer = "hello world".to_string();
app.comment.cursor = 5; app.nav.input_mode = InputMode::Comment;
app.enter_comment_template_picker();
assert_eq!(app.nav.input_mode, InputMode::CommentTemplatePicker);
app.select_comment_template();
assert_eq!(app.comment.buffer, "hellonit: world");
assert_eq!(app.comment.cursor, 5 + "nit: ".len());
assert_eq!(app.nav.input_mode, InputMode::Comment);
assert_eq!(app.ui_layout.template_cursor(), 0);
assert!(app.ui_layout.template_filter().is_empty());
}
#[test]
fn save_comment_review_level_pushes_and_emits_message() {
let (mut app, _dir) = build_test_app();
app.enter_review_comment_mode();
app.comment.buffer = " important review note ".to_string();
app.comment.cursor = app.comment.buffer.len();
app.save_comment();
let comments = &app.engine.session().review_comments;
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].content, "important review note");
let msg = app.message.as_ref().expect("status set");
assert!(
msg.content.contains("Review comment added"),
"expected review-level success message, got: {}",
msg.content
);
assert!(app.dirty);
assert_eq!(app.nav.input_mode, InputMode::Normal);
assert!(app.comment.buffer.is_empty());
}
#[test]
fn save_comment_with_empty_buffer_is_noop_with_message() {
let (mut app, _dir) = build_test_app();
app.enter_review_comment_mode();
app.comment.buffer = " \t ".to_string();
let dirty_before = app.dirty;
let count_before = app.engine.session().review_comments.len();
app.save_comment();
assert_eq!(
app.engine.session().review_comments.len(),
count_before,
"no comment may be appended for an empty buffer"
);
assert_eq!(app.dirty, dirty_before, "dirty flag must not flip");
let msg = app.message.as_ref().expect("status set");
assert!(
msg.content.contains("Comment cannot be empty"),
"expected empty-buffer warning, got: {}",
msg.content
);
assert_eq!(app.nav.input_mode, InputMode::Comment);
}
#[test]
fn save_comment_edit_path_updates_existing_review_comment() {
use travelagent_core::model::{Comment, CommentType};
let (mut app, _dir) = build_test_app();
let existing = Comment::new("old content".into(), CommentType::Note, None);
let edit_id = existing.id.clone();
app.engine.session_mut().review_comments.push(existing);
app.nav.input_mode = InputMode::Comment;
app.comment.is_review_level = true;
app.comment.is_file_level = false;
app.comment.line = None;
app.comment.editing_id = Some(edit_id.clone());
app.comment.buffer = "new content".to_string();
app.comment.cursor = app.comment.buffer.len();
app.save_comment();
let comments = &app.engine.session().review_comments;
assert_eq!(comments.len(), 1, "edit must not append a new comment");
assert_eq!(comments[0].id, edit_id);
assert_eq!(comments[0].content, "new content");
let msg = app.message.as_ref().expect("status set");
assert!(
msg.content.contains("Review comment updated"),
"expected edit-path message, got: {}",
msg.content
);
assert!(app.dirty);
assert_eq!(app.nav.input_mode, InputMode::Normal);
}
#[test]
fn exit_command_mode_returns_to_commit_select_when_launched_from_picker() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::CommitSelect;
app.enter_command_mode();
assert_eq!(app.nav.command_origin, InputMode::CommitSelect);
assert!(matches!(
app.nav.input_mode,
InputMode::Command | InputMode::CommandPalette
));
app.exit_command_mode();
assert_eq!(app.nav.input_mode, InputMode::CommitSelect);
assert_eq!(app.nav.command_origin, InputMode::Normal);
}
#[test]
fn exit_command_mode_returns_to_normal_for_normal_origin() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::Normal;
app.enter_command_mode();
app.exit_command_mode();
assert_eq!(app.nav.input_mode, InputMode::Normal);
assert_eq!(app.nav.command_origin, InputMode::Normal);
}
}