use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use ratatui::style::Color;
use crate::theme::Theme;
use crate::update::UpdateInfo;
use travelagent_core::engine::ReviewEngine;
use travelagent_core::model::{CommentType, DiffFile, DiffLine, FileStatus, LineRange, LineSide};
use travelagent_core::vcs::{CommitInfo, VcsBackend, VcsInfo};
mod agent_action;
pub mod ai_summary;
mod ai_summary_state;
mod annotations;
mod commit_select;
mod construct;
mod error_log;
mod layout;
mod live_mode_state;
mod mcp_listener_state;
pub mod mode;
mod modes;
mod navigation;
mod palette;
mod remote;
mod session;
mod tour;
mod tour_state;
mod ui_layout;
mod viewer_pane_state;
pub use agent_action::AgentActionState;
pub use ai_summary_state::AiSummaryState;
pub use error_log::ErrorLog;
pub use live_mode_state::LiveModeState;
pub use mcp_listener_state::{ListenerState, McpListenerState};
pub use mode::{AppMode, LocalState, RemoteSessionState};
pub use palette::PaletteState;
pub use tour_state::TourSessionState;
pub use ui_layout::UiLayoutState;
pub use viewer_pane_state::{ViewerPaneState, ViewerRender};
const VISIBLE_COMMIT_COUNT: usize = 10;
const COMMIT_PAGE_SIZE: usize = 10;
pub const STAGED_SELECTION_ID: &str = "__trv_staged__";
pub const UNSTAGED_SELECTION_ID: &str = "__trv_unstaged__";
pub const GAP_EXPAND_BATCH: usize = 20;
fn gap_annotation_line_count(is_top_of_file: bool, remaining: usize) -> usize {
if remaining == 0 {
0
} else if is_top_of_file {
if remaining > GAP_EXPAND_BATCH { 2 } else { 1 }
} else {
if remaining >= GAP_EXPAND_BATCH { 3 } else { 1 }
}
}
#[derive(Debug, Clone)]
pub enum FileTreeItem {
Directory {
path: String,
depth: usize,
expanded: bool,
},
File {
file_idx: usize,
depth: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GapId {
pub file_idx: usize,
pub hunk_idx: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ExpandDirection {
Down,
Up,
Both,
}
pub enum GapCursorHit {
Expander(GapId, ExpandDirection),
HiddenLines(GapId),
ExpandedContent(GapId),
}
#[derive(Debug, Clone)]
pub enum AnnotatedLine {
ReviewCommentsHeader,
ReviewComment { comment_idx: usize },
FileHeader { file_idx: usize },
FileComment { file_idx: usize, comment_idx: usize },
Expander {
gap_id: GapId,
direction: ExpandDirection,
},
HiddenLines { gap_id: GapId, count: usize },
ExpandedContext { gap_id: GapId, line_idx: usize },
HunkHeader { file_idx: usize, hunk_idx: usize },
DiffLine {
file_idx: usize,
hunk_idx: usize,
line_idx: usize,
old_lineno: Option<u32>,
new_lineno: Option<u32>,
},
SideBySideLine {
file_idx: usize,
hunk_idx: usize,
del_line_idx: Option<usize>,
add_line_idx: Option<usize>,
old_lineno: Option<u32>,
new_lineno: Option<u32>,
},
LineComment {
file_idx: usize,
line: u32,
side: LineSide,
comment_idx: usize,
},
BinaryOrEmpty { file_idx: usize },
CollapsedFile { file_idx: usize },
OrphanedCommentsHeader {
#[allow(dead_code)]
file_idx: Option<usize>,
count: usize,
},
OrphanedComment {
#[allow(dead_code)]
file_idx: Option<usize>,
orphan_idx: usize,
file_path: std::path::PathBuf,
},
Spacing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FindSourceLineResult {
Exact(usize),
Nearest(usize),
NotFound,
}
pub fn find_source_line(
annotations: &[AnnotatedLine],
current_file: usize,
target_lineno: u32,
) -> FindSourceLineResult {
let mut best: Option<(usize, u32)> = None;
for (idx, annotation) in annotations.iter().enumerate() {
let (file_idx, new_lineno) = match annotation {
AnnotatedLine::DiffLine {
file_idx,
new_lineno,
..
} => (*file_idx, *new_lineno),
AnnotatedLine::SideBySideLine {
file_idx,
new_lineno,
..
} => (*file_idx, *new_lineno),
_ => continue,
};
if file_idx != current_file {
continue;
}
if let Some(ln) = new_lineno {
let dist = ln.abs_diff(target_lineno);
if dist == 0 {
return FindSourceLineResult::Exact(idx);
}
if best.is_none_or(|(_, b)| dist < b) {
best = Some((idx, dist));
}
}
}
match best {
Some((idx, _)) => FindSourceLineResult::Nearest(idx),
None => FindSourceLineResult::NotFound,
}
}
pub fn resolve_cursor_to_path_line(
annotations: &[AnnotatedLine],
cursor_line: usize,
diff_files: &[travelagent_core::model::DiffFile],
) -> Option<(std::path::PathBuf, u32)> {
let (file_idx, old_lineno, new_lineno) = match annotations.get(cursor_line)? {
AnnotatedLine::DiffLine {
file_idx,
old_lineno,
new_lineno,
..
}
| AnnotatedLine::SideBySideLine {
file_idx,
old_lineno,
new_lineno,
..
} => (*file_idx, *old_lineno, *new_lineno),
_ => return None,
};
let line = new_lineno.or(old_lineno)?;
let path = diff_files.get(file_idx)?.display_path()?.clone();
Some((path, line))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
Normal,
Comment,
Command,
Search,
Help,
Confirm,
CommitSelect,
VisualSelect,
ReviewSubmit,
CommandPalette,
ReactionPicker,
CommentTemplatePicker,
MentalModelEdit,
}
#[derive(Debug, Clone, Default)]
pub struct MentalModelEditState {
pub drafts: [String; 4],
pub focused: usize,
}
pub const MENTAL_MODEL_LABELS: [&str; 4] = [
"What should this change do?",
"What shouldn't it do?",
"What could go wrong?",
"What assumptions underlie it?",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffSource {
WorkingTree,
Staged,
Unstaged,
StagedAndUnstaged,
CommitRange(Vec<String>),
StagedUnstagedAndCommits(Vec<String>),
Remote { pr_title: String, pr_number: u64 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfirmAction {
CopyAndQuit,
Merge,
RestartReview,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusedPanel {
FileList,
Diff,
CommitSelector,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemotePanel {
Files,
Description,
Conversation,
Commits,
Sparring,
}
impl RemotePanel {
#[must_use]
pub fn from_digit(digit: u8, spar_mode: bool) -> Option<RemotePanel> {
match digit {
1 => Some(RemotePanel::Files),
2 => Some(RemotePanel::Description),
3 => Some(RemotePanel::Conversation),
4 => Some(RemotePanel::Commits),
5 if spar_mode => Some(RemotePanel::Sparring),
_ => None,
}
}
}
#[cfg(test)]
mod remote_panel_tests {
use super::RemotePanel;
#[test]
fn digits_one_to_four_map_regardless_of_spar_mode() {
for spar in [false, true] {
assert_eq!(RemotePanel::from_digit(1, spar), Some(RemotePanel::Files));
assert_eq!(
RemotePanel::from_digit(2, spar),
Some(RemotePanel::Description)
);
assert_eq!(
RemotePanel::from_digit(3, spar),
Some(RemotePanel::Conversation)
);
assert_eq!(RemotePanel::from_digit(4, spar), Some(RemotePanel::Commits));
}
}
#[test]
fn digit_five_requires_spar_mode() {
assert_eq!(RemotePanel::from_digit(5, false), None);
assert_eq!(
RemotePanel::from_digit(5, true),
Some(RemotePanel::Sparring)
);
}
#[test]
fn digits_out_of_range_return_none() {
assert_eq!(RemotePanel::from_digit(0, true), None);
assert_eq!(RemotePanel::from_digit(6, true), None);
assert_eq!(RemotePanel::from_digit(9, true), None);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffViewMode {
Unified,
SideBySide,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MessageType {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Message {
pub content: String,
pub message_type: MessageType,
}
pub const FLASH_MESSAGE_TTL: Duration = Duration::from_secs(2);
pub const MCP_NOTIFY_QUEUE_CAP: usize = 256;
#[derive(Debug, Clone)]
pub struct AgentFlash {
pub text: String,
pub expires_at: Instant,
}
#[derive(Debug, Clone)]
pub struct AgentGhost {
pub file_idx: usize,
pub path: String,
}
#[derive(Debug, Clone)]
pub enum ConfirmationStatus {
Pending,
Rejected {
reason: RejectReason,
},
Executing,
Succeeded {
result_json: String,
},
Failed {
error: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RejectReason {
User,
Timeout,
AgentCancelled,
AlreadyPending,
}
impl RejectReason {
pub fn as_str(self) -> &'static str {
match self {
RejectReason::User => "user",
RejectReason::Timeout => "timeout",
RejectReason::AgentCancelled => "agent_cancelled",
RejectReason::AlreadyPending => "already_pending",
}
}
}
#[derive(Debug, Clone)]
pub enum AgentActionKind {
SubmitReview {
verdict: travelagent_core::forge::ReviewVerdict,
body: String,
},
SetMentalModel {
mental_model: travelagent_core::model::MentalModel,
},
AcceptGeneratedTest {
test_path: String,
test_body: String,
spec_id: String,
},
}
impl AgentActionKind {
pub fn wire_name(&self) -> &'static str {
match self {
AgentActionKind::SubmitReview { .. } => "submit_review",
AgentActionKind::SetMentalModel { .. } => "set_mental_model",
AgentActionKind::AcceptGeneratedTest { .. } => "accept_generated_test",
}
}
}
#[derive(Debug, Clone)]
pub struct PendingAgentAction {
pub id: String,
pub kind: AgentActionKind,
pub status: ConfirmationStatus,
pub proposed_at: chrono::DateTime<chrono::Utc>,
pub proposed_at_monotonic: Instant,
pub decided_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug)]
pub enum ForgeSubmitResult {
Ok,
Err(String),
}
#[derive(Debug, Clone)]
pub struct LastAgentDecision {
pub id: String,
pub decision: &'static str,
pub reason: Option<&'static str>,
pub decided_at: chrono::DateTime<chrono::Utc>,
}
pub const CONFIRMATION_TIMEOUT: Duration = Duration::from_secs(300);
pub const CONFIRMATION_HISTORY_CAP: usize = 16;
#[derive(Debug, Clone)]
pub enum McpNotify {
FileChanged { files: Vec<String> },
CommentAdded {
file: String,
line: Option<u32>,
author: &'static str,
},
ReviewSubmitted { verdict: Option<String>, at: String },
AgentActionProposed { id: String, kind: &'static str },
AgentActionDecided {
id: String,
decision: &'static str,
reason: Option<&'static str>,
},
Hangup { deadline_ms: u64, reason: String },
TourRequest { commit_ids: Vec<String> },
}
impl McpNotify {
pub fn method_suffix(&self) -> &'static str {
match self {
Self::FileChanged { .. } => "file_changed",
Self::CommentAdded { .. } => "comment_added",
Self::ReviewSubmitted { .. } => "review_submitted",
Self::AgentActionProposed { .. } => "agent_action_proposed",
Self::AgentActionDecided { .. } => "agent_action_decided",
Self::Hangup { .. } => "hangup",
Self::TourRequest { .. } => "tour_request",
}
}
}
pub struct CommentEditState {
pub buffer: String,
pub cursor: usize,
pub comment_type: CommentType,
pub types: Vec<CommentTypeDefinition>,
pub is_review_level: bool,
pub is_file_level: bool,
pub line: Option<(u32, LineSide)>,
pub editing_id: Option<String>,
pub visual_anchor: Option<(u32, LineSide)>,
pub line_range: Option<(LineRange, LineSide)>,
pub cursor_screen_pos: Option<(u16, u16)>,
}
pub struct CommitSelectionState {
pub list: Vec<CommitInfo>,
pub cursor: usize,
pub scroll_offset: usize,
pub viewport_height: usize,
pub selection_range: Option<(usize, usize)>,
pub visible_count: usize,
pub page_size: usize,
pub has_more: bool,
}
pub struct GapExpansionState {
pub expanded_top: HashMap<GapId, Vec<DiffLine>>,
pub expanded_bottom: HashMap<GapId, Vec<DiffLine>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GranularityHint {
Coarser,
Finer,
}
impl GranularityHint {
pub fn id(&self) -> &'static str {
match self {
Self::Coarser => "coarser",
Self::Finer => "finer",
}
}
}
#[cfg(test)]
mod resolve_cursor_to_path_line_tests {
use super::*;
use std::path::PathBuf;
use travelagent_core::model::{DiffFile, FileStatus};
fn make_file(new_path: &str) -> DiffFile {
DiffFile {
old_path: None,
new_path: Some(PathBuf::from(new_path)),
status: FileStatus::Modified,
hunks: vec![],
is_binary: false,
is_too_large: false,
is_commit_message: false,
}
}
#[test]
fn cursor_resolve_to_path_line_unified() {
let files = vec![make_file("src/foo.rs")];
let annotations = vec![
AnnotatedLine::FileHeader { file_idx: 0 },
AnnotatedLine::HunkHeader {
file_idx: 0,
hunk_idx: 0,
},
AnnotatedLine::DiffLine {
file_idx: 0,
hunk_idx: 0,
line_idx: 0,
old_lineno: Some(10),
new_lineno: Some(12),
},
];
let resolved = resolve_cursor_to_path_line(&annotations, 2, &files);
assert_eq!(resolved, Some((PathBuf::from("src/foo.rs"), 12)));
}
#[test]
fn cursor_resolve_to_path_line_sbs() {
let files = vec![make_file("lib/bar.rs")];
let annotations = vec![
AnnotatedLine::FileHeader { file_idx: 0 },
AnnotatedLine::SideBySideLine {
file_idx: 0,
hunk_idx: 0,
del_line_idx: None,
add_line_idx: Some(0),
old_lineno: Some(7),
new_lineno: Some(8),
},
AnnotatedLine::SideBySideLine {
file_idx: 0,
hunk_idx: 0,
del_line_idx: Some(1),
add_line_idx: None,
old_lineno: Some(9),
new_lineno: None,
},
];
let new_side = resolve_cursor_to_path_line(&annotations, 1, &files);
assert_eq!(new_side, Some((PathBuf::from("lib/bar.rs"), 8)));
let old_side = resolve_cursor_to_path_line(&annotations, 2, &files);
assert_eq!(old_side, Some((PathBuf::from("lib/bar.rs"), 9)));
}
#[test]
fn cursor_resolve_to_path_line_on_header_returns_none() {
let files = vec![make_file("src/foo.rs")];
let annotations = vec![
AnnotatedLine::ReviewCommentsHeader,
AnnotatedLine::FileHeader { file_idx: 0 },
AnnotatedLine::HunkHeader {
file_idx: 0,
hunk_idx: 0,
},
AnnotatedLine::Spacing,
];
for idx in 0..annotations.len() {
assert_eq!(
resolve_cursor_to_path_line(&annotations, idx, &files),
None,
"annotation index {idx} should resolve to None",
);
}
assert_eq!(resolve_cursor_to_path_line(&annotations, 999, &files), None,);
}
}
#[cfg(test)]
mod agent_flash_tests {
use super::*;
#[test]
fn set_flash_message_stores_text_and_expiry() {
let fresh = AgentFlash {
text: "\u{1f916} agent: jumped to foo.rs".to_string(),
expires_at: Instant::now() + Duration::from_secs(10),
};
assert!(fresh.expires_at > Instant::now());
assert!(fresh.text.contains("jumped to foo.rs"));
}
#[test]
fn expired_flash_is_in_the_past() {
let expired = AgentFlash {
text: "old".to_string(),
expires_at: Instant::now() - Duration::from_millis(1),
};
assert!(expired.expires_at <= Instant::now());
}
}
#[cfg(test)]
mod granularity_hint_tests {
use super::*;
#[test]
fn granularity_hint_has_stable_ids() {
assert_eq!(GranularityHint::Coarser.id(), "coarser");
assert_eq!(GranularityHint::Finer.id(), "finer");
}
}
pub struct InlineCommitSelectorState {
pub commits: Vec<CommitInfo>,
pub visible: bool,
pub diff_cache: HashMap<(usize, usize), Vec<DiffFile>>,
pub range_diff_files: Option<Vec<DiffFile>>,
pub saved_selection: Option<(usize, usize)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NavigationState {
pub input_mode: InputMode,
pub focused_panel: FocusedPanel,
pub diff_view_mode: DiffViewMode,
pub command_origin: InputMode,
}
impl Default for NavigationState {
fn default() -> Self {
Self {
input_mode: InputMode::Normal,
focused_panel: FocusedPanel::Diff,
diff_view_mode: DiffViewMode::Unified,
command_origin: InputMode::Normal,
}
}
}
pub struct App {
pub theme: Theme,
pub vcs: Box<dyn VcsBackend>,
pub vcs_info: VcsInfo,
pub engine: ReviewEngine,
pub diff_files: Vec<DiffFile>,
pub diff_source: DiffSource,
pub nav: NavigationState,
pub file_list_state: FileListState,
pub diff_state: DiffState,
pub help_state: HelpState,
pub palette: PaletteState,
pub search_buffer: String,
pub last_search_pattern: Option<String>,
pub comment: CommentEditState,
pub mental_model_edit: MentalModelEditState,
pub mental_model_byte_limit: usize,
pub blind_mode: bool,
pub blind_patterns: Vec<String>,
pub spar_mode: bool,
pub spec_statuses:
std::collections::HashMap<String, travelagent_core::sparring::SparringStatus>,
pub sparring_cursor: usize,
pub commit_select: CommitSelectionState,
pub should_quit: bool,
pub discard_on_exit: bool,
pub dirty: bool,
pub quit_warned: bool,
pub confirm_on_quit: bool,
pub comment_templates: Vec<(String, String)>,
pub message: Option<Message>,
pub error_log: ErrorLog,
pub agent_flash: Option<AgentFlash>,
pub viewport_pinned: bool,
pub agent_ghost: Option<AgentGhost>,
pub notify_queue: VecDeque<McpNotify>,
pub forge_warn_queue: std::sync::Arc<std::sync::Mutex<VecDeque<String>>>,
pub pending_confirm: Option<ConfirmAction>,
pub supports_keyboard_enhancement: bool,
pub ui_layout: UiLayoutState,
pub gaps: GapExpansionState,
pub line_annotations: Vec<AnnotatedLine>,
pub output_to_stdout: bool,
pub pending_stdout_output: Option<String>,
pub update_info: Option<UpdateInfo>,
pub pending_count: Option<usize>,
pub inline_selector: InlineCommitSelectorState,
pub path_filter: Option<String>,
pub export_legend: bool,
pub mode: AppMode,
pub runtime_handle: tokio::runtime::Handle,
pub auto_stage: bool,
pub tour: TourSessionState,
pub pending_external_edit: bool,
pub pending_open_file_editor: Option<(std::path::PathBuf, u32)>,
pub word_diff_enabled: bool,
pub markdown_rendering_enabled: bool,
pub risk_border_colors: bool,
pub ai: AiSummaryState,
pub reaction_picker_cursor: usize,
pub tab_width: usize,
pub risk_config: travelagent_core::risk::RiskConfig,
pub auto_collapse_cfg: travelagent_core::auto_collapse::AutoCollapseConfig,
pub live: LiveModeState,
pub agent_action: AgentActionState,
pub mcp_listener: McpListenerState,
pub viewer: ViewerPaneState,
pub peeked_reviewed: HashSet<PathBuf>,
pub mcp_peer_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
pub pending_tour_request: Option<Vec<String>>,
pub pending_tour_request_poll: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommentTypeDefinition {
pub id: String,
pub label: String,
pub definition: Option<String>,
pub color: Option<Color>,
}
#[derive(Default)]
pub struct FileListState {
pub list_state: ratatui::widgets::ListState,
pub scroll_x: usize,
pub viewport_width: usize, pub viewport_height: usize, pub max_content_width: usize, }
impl FileListState {
pub fn selected(&self) -> usize {
self.list_state.selected().unwrap_or(0)
}
pub fn select(&mut self, index: usize) {
self.list_state.select(Some(index));
}
pub fn scroll_left(&mut self, cols: usize) {
self.scroll_x = self.scroll_x.saturating_sub(cols);
}
pub fn scroll_right(&mut self, cols: usize) {
let max_scroll_x = self.max_content_width.saturating_sub(self.viewport_width);
self.scroll_x = (self.scroll_x.saturating_add(cols)).min(max_scroll_x);
}
}
#[derive(Debug)]
pub struct DiffState {
pub scroll_offset: usize,
pub scroll_x: usize,
pub cursor_line: usize,
pub current_file_idx: usize,
pub viewport_height: usize,
pub viewport_width: usize,
pub max_content_width: usize,
pub wrap_lines: bool,
pub visible_line_count: usize,
}
impl Default for DiffState {
fn default() -> Self {
Self {
scroll_offset: 0,
scroll_x: 0,
cursor_line: 0,
current_file_idx: 0,
viewport_height: 0,
viewport_width: 0,
max_content_width: 0,
wrap_lines: true,
visible_line_count: 0,
}
}
}
#[derive(Debug, Default)]
pub struct HelpState {
pub scroll_offset: usize,
pub viewport_height: usize,
pub total_lines: usize, }
impl App {
#[allow(dead_code)]
pub fn is_remote(&self) -> bool {
self.mode.is_remote()
}
pub fn remote(&self) -> Option<&RemoteSessionState> {
self.mode.remote()
}
pub fn remote_mut(&mut self) -> Option<&mut RemoteSessionState> {
self.mode.remote_mut()
}
pub fn mcp_peer_count(&self) -> usize {
self.mcp_peer_count
.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn current_file(&self) -> Option<&DiffFile> {
self.diff_files.get(self.diff_state.current_file_idx)
}
pub fn current_file_path(&self) -> Option<&PathBuf> {
self.current_file()
.map(travelagent_core::model::DiffFile::display_path_lossy)
}
pub fn refresh_viewer_content(&mut self) -> Result<(), String> {
if matches!(self.diff_source, DiffSource::Remote { .. }) {
return Err("Viewer is only available for local diffs".to_string());
}
let Some(file) = self.current_file() else {
return Err("No file selected".to_string());
};
if file.is_binary {
return Err("Viewer can't display a binary file".to_string());
}
if file.status == travelagent_core::model::FileStatus::Deleted {
return Err("Viewer can't display a deleted file".to_string());
}
let rel = file.display_path_lossy().clone();
if self.viewer.has_content_for(&rel) {
return Ok(());
}
self.viewer.coerce_render_for(&rel);
let abs = self.vcs_info.root_path.join(&rel);
match std::fs::read_to_string(&abs) {
Ok(text) => {
let lines: Vec<String> = text.lines().map(str::to_string).collect();
self.viewer.set_content(rel, lines);
Ok(())
}
Err(e) => Err(format!("Couldn't read {}: {e}", rel.display())),
}
}
pub fn toggle_viewer(&mut self) {
if self.viewer.is_active() {
self.viewer.deactivate();
self.set_message("Viewer: off (showing diff)");
return;
}
if let Err(msg) = self.refresh_viewer_content() {
self.set_warning(msg);
return;
}
if let Some(path) = self.current_file_path().cloned() {
self.viewer.coerce_render_for(&path);
}
self.viewer.activate();
self.set_message("Viewer: on (t to close, T for raw/rendered)");
}
pub fn toggle_viewer_render(&mut self) {
if !self.viewer.is_active() {
self.set_message("Viewer not active (press t to open)");
return;
}
let Some(path) = self.current_file_path().cloned() else {
self.set_message("No file selected");
return;
};
match self.viewer.toggle_render(&path) {
Some(crate::app::ViewerRender::Rendered) => self.set_message("Viewer: rendered"),
Some(crate::app::ViewerRender::Raw) => self.set_message("Viewer: raw"),
None => self.set_warning("Rendered view: markdown only"),
}
}
pub fn viewer_viewport_half(&self) -> usize {
(self.diff_state.viewport_height / 2).max(1)
}
pub fn viewer_viewport_full(&self) -> usize {
self.diff_state.viewport_height.max(1)
}
pub fn toggle_reviewed(&mut self) {
let file_idx = self.diff_state.current_file_idx;
self.toggle_reviewed_for_file_idx(file_idx, true);
}
pub fn is_file_collapsed(&self, file_idx: usize) -> bool {
let Some(file) = self.diff_files.get(file_idx) else {
return false;
};
let path = file.display_path_lossy();
let explicit = self
.engine
.session()
.files
.get(path.as_path())
.and_then(|r| r.collapsed);
let (additions, deletions) = file.stat();
travelagent_core::auto_collapse::effective_collapsed(
explicit,
path.as_path(),
additions + deletions,
&self.auto_collapse_cfg,
)
}
pub fn auto_collapse_reason_for(
&self,
file_idx: usize,
) -> Option<travelagent_core::auto_collapse::CollapseReason> {
let file = self.diff_files.get(file_idx)?;
let (additions, deletions) = file.stat();
travelagent_core::auto_collapse::auto_collapse_reason(
file.display_path_lossy().as_path(),
additions + deletions,
&self.auto_collapse_cfg,
)
}
pub fn toggle_file_collapse(&mut self) {
let file_idx = self.diff_state.current_file_idx;
let Some(file) = self.diff_files.get(file_idx) else {
return;
};
let path = file.display_path_lossy().clone();
let (additions, deletions) = file.stat();
let changed = additions + deletions;
let auto_cfg = &self.auto_collapse_cfg;
let auto = travelagent_core::auto_collapse::should_auto_collapse(
path.as_path(),
changed,
auto_cfg,
);
self.engine
.session_mut()
.add_file(path.clone(), file.status);
if let Some(review) = self.engine.session_mut().get_file_mut(&path) {
review.collapsed = Some(match review.collapsed {
None => !auto,
Some(x) => !x,
});
self.dirty = true;
}
self.rebuild_annotations();
}
pub fn toggle_reviewed_for_file_idx(&mut self, file_idx: usize, adjust_cursor: bool) {
let Some(path) = self
.diff_files
.get(file_idx)
.map(|file| file.display_path_lossy().clone())
else {
return;
};
if let Some(review) = self.engine.session_mut().get_file_mut(&path) {
review.reviewed = !review.reviewed;
let now_reviewed = review.reviewed;
self.dirty = true;
self.rebuild_annotations();
if now_reviewed
&& self.auto_stage
&& matches!(
self.diff_source,
DiffSource::WorkingTree | DiffSource::StagedAndUnstaged
)
&& self.vcs_info.vcs_type == travelagent_core::vcs::VcsType::Git
&& let Some(file) = self.diff_files.get(file_idx)
{
let file_path = file.display_path_lossy();
match std::process::Command::new("git")
.arg("add")
.arg("--")
.arg(file_path.as_os_str())
.current_dir(&self.vcs_info.root_path)
.output()
{
Ok(out) if out.status.success() => {}
_ => self.set_warning(format!("Failed to stage {}", file_path.display())),
}
}
if adjust_cursor {
self.diff_state.current_file_idx = file_idx;
let header_line = self.calculate_file_scroll_offset(file_idx);
self.diff_state.cursor_line = header_line;
self.ensure_cursor_visible();
}
}
}
pub fn file_count(&self) -> usize {
self.diff_files.len()
}
pub fn reviewed_count(&self) -> usize {
self.engine.session().reviewed_count()
}
pub fn diff_stat(&self) -> (usize, usize, usize) {
let mut additions = 0;
let mut deletions = 0;
for file in &self.diff_files {
let (a, d) = file.stat();
additions += a;
deletions += d;
}
(self.diff_files.len(), additions, deletions)
}
pub fn set_message(&mut self, msg: impl Into<String>) {
self.message = Some(Message {
content: msg.into(),
message_type: MessageType::Info,
});
}
pub fn set_warning(&mut self, msg: impl Into<String>) {
self.message = Some(Message {
content: msg.into(),
message_type: MessageType::Warning,
});
}
pub fn set_error(&mut self, msg: impl Into<String>) {
let msg = Message {
content: msg.into(),
message_type: MessageType::Error,
};
self.error_log.push(msg.clone());
self.message = Some(msg);
}
pub fn recall_next_error(&mut self) -> bool {
if let Some(entry) = self.error_log.next_recall() {
self.message = Some(entry.clone());
true
} else {
false
}
}
pub fn set_flash_message(&mut self, text: impl Into<String>, ttl: Duration) {
self.agent_flash = Some(AgentFlash {
text: text.into(),
expires_at: Instant::now() + ttl,
});
}
pub fn current_flash_text(&self) -> Option<&str> {
let flash = self.agent_flash.as_ref()?;
if flash.expires_at > Instant::now() {
Some(flash.text.as_str())
} else {
None
}
}
pub fn toggle_viewport_pin(&mut self) {
self.viewport_pinned = !self.viewport_pinned;
if !self.viewport_pinned {
self.agent_ghost = None;
}
let msg = if self.viewport_pinned {
"Viewport pinned — agent navigation won't move your cursor (Ctrl+G to jump to agent)"
} else {
"Viewport following agent navigation (Ctrl+P to pin)"
};
self.set_message(msg);
}
pub fn mark_all_reviewed(&mut self) -> usize {
let paths: Vec<(PathBuf, FileStatus)> = self
.diff_files
.iter()
.map(|f| (f.display_path_lossy().clone(), f.status))
.collect();
let mut newly = 0;
for (path, status) in paths {
self.engine.session_mut().add_file(path.clone(), status);
if let Some(review) = self.engine.session_mut().get_file_mut(&path)
&& !review.reviewed
{
review.reviewed = true;
newly += 1;
}
}
if newly > 0 {
self.dirty = true;
self.rebuild_annotations();
}
newly
}
pub fn unmark_all_reviewed(&mut self) -> usize {
let paths: Vec<PathBuf> = self
.diff_files
.iter()
.map(|f| f.display_path_lossy().clone())
.collect();
let mut cleared = 0;
for path in paths {
if let Some(review) = self.engine.session_mut().get_file_mut(&path)
&& review.reviewed
{
review.reviewed = false;
cleared += 1;
}
}
if cleared > 0 {
self.dirty = true;
self.rebuild_annotations();
}
cleared
}
pub fn restart_review(&mut self) -> usize {
let mut cleared = 0;
for review in self.engine.session_mut().files.values_mut() {
if review.reviewed {
review.reviewed = false;
cleared += 1;
}
}
self.peeked_reviewed.clear();
if cleared > 0 {
self.dirty = true;
self.rebuild_annotations();
}
cleared
}
pub fn reviewed_file_count(&self) -> usize {
self.engine
.session()
.files
.values()
.filter(|r| r.reviewed)
.count()
}
pub fn toggle_reviewed_peek(&mut self) {
let Some(path) = self.current_file_path().cloned() else {
return;
};
if !self.engine.session().is_file_reviewed(&path) {
self.toggle_file_collapse();
return;
}
if self.peeked_reviewed.contains(&path) {
self.peeked_reviewed.remove(&path);
self.set_message("Reviewed file folded");
} else {
self.peeked_reviewed.insert(path);
self.set_message("Peeking reviewed file (still marked reviewed)");
}
self.rebuild_annotations();
}
pub fn is_reviewed_peeked(&self, path: &std::path::Path) -> bool {
self.peeked_reviewed.contains(path)
}
pub fn push_notify(&mut self, notify: McpNotify) {
if self.notify_queue.len() >= MCP_NOTIFY_QUEUE_CAP {
self.notify_queue.pop_front();
}
self.notify_queue.push_back(notify);
}
pub fn find_forge_action(&self, id: &str) -> Option<&PendingAgentAction> {
self.agent_action.find(id)
}
pub fn forge_modal_should_capture(&self) -> bool {
matches!(self.nav.input_mode, InputMode::Normal) && self.agent_action.has_waiting_pending()
}
pub fn refresh_spec_statuses(&mut self) {
use travelagent_core::model::CommentType;
use travelagent_core::sparring::{SparringStatus, scan_spec_links};
let mut active_ids: Vec<String> = Vec::new();
let session = self.engine.session();
for c in &session.review_comments {
if matches!(c.comment_type, CommentType::Spec) && !c.resolved {
active_ids.push(c.id.clone());
}
}
for fr in session.files.values() {
for c in &fr.file_comments {
if matches!(c.comment_type, CommentType::Spec) && !c.resolved {
active_ids.push(c.id.clone());
}
}
for cs in fr.line_comments.values() {
for c in cs {
if matches!(c.comment_type, CommentType::Spec) && !c.resolved {
active_ids.push(c.id.clone());
}
}
}
for c in &fr.orphaned_comments {
if matches!(c.comment_type, CommentType::Spec) && !c.resolved {
active_ids.push(c.id.clone());
}
}
}
let links = scan_spec_links(&self.vcs_info.root_path, &active_ids);
let mut statuses: std::collections::HashMap<String, SparringStatus> =
std::collections::HashMap::new();
for id in active_ids {
let status = if links.contains_key(&id) {
SparringStatus::Linked
} else {
SparringStatus::Unlinked
};
statuses.insert(id, status);
}
self.spec_statuses = statuses;
}
fn write_generated_test(
&self,
test_path: &str,
test_body: &str,
) -> Result<std::path::PathBuf, String> {
let rel = std::path::Path::new(test_path);
if rel.is_absolute() {
return Err(format!("test_path must be repo-relative: {test_path}"));
}
if rel.components().any(|c| {
matches!(
c,
std::path::Component::ParentDir | std::path::Component::RootDir
)
}) {
return Err(format!(
"test_path must not traverse outside the repo: {test_path}"
));
}
let abs = self.vcs_info.root_path.join(rel);
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("failed to create directory {}: {e}", parent.display()))?;
}
std::fs::write(&abs, test_body)
.map_err(|e| format!("failed to write {}: {e}", abs.display()))?;
Ok(abs)
}
pub fn approve_pending_agent_action(&mut self) {
use travelagent_core::forge::NewReview;
let Some(mut action) = self.agent_action.take_pending() else {
return;
};
if !matches!(action.status, ConfirmationStatus::Pending) {
self.agent_action.put_back_pending(action);
return;
}
if let AgentActionKind::SetMentalModel { mental_model } = action.kind.clone() {
let id = action.id.clone();
let now = chrono::Utc::now();
self.engine
.session_mut()
.commit_mental_model(mental_model, now);
self.dirty = true;
let result_json = serde_json::to_string(&serde_json::json!({
"ok": true,
"kind": "set_mental_model",
"at": now.to_rfc3339(),
}))
.unwrap_or_else(|_| r#"{"ok":true}"#.to_string());
action.status = ConfirmationStatus::Succeeded { result_json };
action.decided_at = Some(now);
let decided_id = action.id.clone();
self.agent_action.archive_with_decision(
action,
LastAgentDecision {
id: decided_id.clone(),
decision: "succeeded",
reason: None,
decided_at: now,
},
);
self.push_notify(McpNotify::AgentActionDecided {
id: decided_id,
decision: "approved",
reason: None,
});
self.set_message(format!("Mental model overwrite approved (id {id})"));
return;
}
if let AgentActionKind::AcceptGeneratedTest {
test_path,
test_body,
spec_id,
} = action.kind.clone()
{
let id = action.id.clone();
let now = chrono::Utc::now();
match self.write_generated_test(&test_path, &test_body) {
Ok(abs_path) => {
let result_json = serde_json::to_string(&serde_json::json!({
"ok": true,
"kind": "accept_generated_test",
"test_path": test_path,
"abs_path": abs_path.to_string_lossy(),
"spec_id": spec_id,
"at": now.to_rfc3339(),
}))
.unwrap_or_else(|_| r#"{"ok":true}"#.to_string());
action.status = ConfirmationStatus::Succeeded { result_json };
action.decided_at = Some(now);
let decided_id = action.id.clone();
self.agent_action.archive_with_decision(
action,
LastAgentDecision {
id: decided_id.clone(),
decision: "succeeded",
reason: None,
decided_at: now,
},
);
self.push_notify(McpNotify::AgentActionDecided {
id: decided_id,
decision: "approved",
reason: None,
});
self.set_message(format!("Generated test landed at {test_path} (id {id})"));
self.refresh_spec_statuses();
}
Err(err) => {
action.status = ConfirmationStatus::Failed { error: err.clone() };
action.decided_at = Some(now);
let decided_id = action.id.clone();
self.agent_action.archive_with_decision(
action,
LastAgentDecision {
id: decided_id.clone(),
decision: "failed",
reason: None,
decided_at: now,
},
);
self.push_notify(McpNotify::AgentActionDecided {
id: decided_id,
decision: "failed",
reason: None,
});
self.set_error(format!("Generated test write failed: {err}"));
}
}
return;
}
let AgentActionKind::SubmitReview { verdict, body } = action.kind.clone() else {
self.agent_action.put_back_pending(action);
return;
};
let id = action.id.clone();
action.status = ConfirmationStatus::Executing;
self.agent_action.put_back_pending(action);
let (forge_arc, pr_id) = match self.remote() {
Some(r) => match r.forge.as_ref() {
Some(f) => (std::sync::Arc::clone(f), r.pr_id.clone()),
None => {
let (tx, rx) = tokio::sync::oneshot::channel();
let _ = tx.send(ForgeSubmitResult::Err(
"no forge attached (demo mode?)".to_string(),
));
self.agent_action.set_completion(rx);
return;
}
},
None => {
let (tx, rx) = tokio::sync::oneshot::channel();
let _ = tx.send(ForgeSubmitResult::Err(
"no longer in remote mode".to_string(),
));
self.agent_action.set_completion(rx);
return;
}
};
let (tx, rx) = tokio::sync::oneshot::channel::<ForgeSubmitResult>();
self.agent_action.set_completion(rx);
let review = NewReview {
verdict,
body,
comments: vec![],
};
self.runtime_handle.spawn(async move {
let result = match forge_arc.submit_review(&pr_id, review).await {
Ok(()) => ForgeSubmitResult::Ok,
Err(e) => ForgeSubmitResult::Err(format!("{e}")),
};
let _ = tx.send(result);
});
self.set_message(format!(
"Forge submit_review approved (id {id}): awaiting forge response"
));
}
pub fn poll_forge_completion(&mut self) {
use travelagent_core::forge::ReviewVerdict;
let Some(mut rx) = self.agent_action.take_completion() else {
return;
};
let result = match rx.try_recv() {
Ok(r) => r,
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
self.agent_action.put_back_completion(rx);
return;
}
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
ForgeSubmitResult::Err("forge worker dropped without reply".to_string())
}
};
let Some(mut action) = self.agent_action.take_pending() else {
return;
};
let id = action.id.clone();
let verdict_label: &'static str = match &action.kind {
AgentActionKind::SubmitReview { verdict, .. } => match verdict {
ReviewVerdict::Comment => "Comment",
ReviewVerdict::Approve => "Approve",
ReviewVerdict::RequestChanges => "Request Changes",
},
AgentActionKind::SetMentalModel { .. }
| AgentActionKind::AcceptGeneratedTest { .. } => {
self.agent_action.put_back_pending(action);
return;
}
};
let now = chrono::Utc::now();
action.decided_at = Some(now);
match result {
ForgeSubmitResult::Ok => {
let result_json = serde_json::to_string(&serde_json::json!({
"ok": true,
"verdict": verdict_label,
"at": now.to_rfc3339(),
}))
.unwrap_or_else(|_| r#"{"ok":true}"#.to_string());
action.status = ConfirmationStatus::Succeeded {
result_json: result_json.clone(),
};
self.push_notify(McpNotify::ReviewSubmitted {
verdict: Some(verdict_label.to_string()),
at: now.to_rfc3339(),
});
let head_sha = self
.remote()
.and_then(|r| r.pr_metadata.as_ref().map(|m| m.head_sha.clone()));
self.engine.session_mut().last_review_submitted_at = Some(now);
if let Some(sha) = head_sha {
self.engine.session_mut().last_review_sha = Some(sha);
}
self.dirty = true;
self.set_message(format!(
"Forge submit_review approved (id {id}): {verdict_label}"
));
}
ForgeSubmitResult::Err(error) => {
action.status = ConfirmationStatus::Failed {
error: error.clone(),
};
self.set_error(format!(
"Forge submit_review approved (id {id}) but failed: {error}"
));
}
}
let decided_id = action.id.clone();
let terminal_decision: &'static str = match &action.status {
ConfirmationStatus::Succeeded { .. } => "succeeded",
ConfirmationStatus::Failed { .. } => "failed",
_ => "approved",
};
self.agent_action.archive_with_decision(
action,
LastAgentDecision {
id: decided_id.clone(),
decision: terminal_decision,
reason: None,
decided_at: now,
},
);
self.push_notify(McpNotify::AgentActionDecided {
id: decided_id,
decision: "approved",
reason: None,
});
}
pub fn reject_pending_agent_action(&mut self) {
self.reject_pending_agent_action_with_reason(RejectReason::User);
}
fn reject_pending_agent_action_with_reason(&mut self, reason: RejectReason) {
let Some(mut action) = self.agent_action.take_pending() else {
return;
};
if !matches!(action.status, ConfirmationStatus::Pending) {
self.agent_action.put_back_pending(action);
return;
}
let now = chrono::Utc::now();
action.status = ConfirmationStatus::Rejected { reason };
action.decided_at = Some(now);
let id = action.id.clone();
self.agent_action.archive_with_decision(
action,
LastAgentDecision {
id: id.clone(),
decision: "rejected",
reason: Some(reason.as_str()),
decided_at: now,
},
);
self.set_message(format!("Rejected agent's forge proposal (id {id})"));
self.push_notify(McpNotify::AgentActionDecided {
id,
decision: "rejected",
reason: Some(reason.as_str()),
});
}
pub fn tick_agent_action_timeout(&mut self) {
let is_stale = self.agent_action.pending().is_some_and(|p| {
matches!(p.status, ConfirmationStatus::Pending)
&& p.proposed_at_monotonic.elapsed() > CONFIRMATION_TIMEOUT
});
if !is_stale {
return;
}
if let Some(mut action) = self.agent_action.take_pending() {
let now = chrono::Utc::now();
action.status = ConfirmationStatus::Rejected {
reason: RejectReason::Timeout,
};
action.decided_at = Some(now);
let id = action.id.clone();
self.agent_action.archive_with_decision(
action,
LastAgentDecision {
id: id.clone(),
decision: "rejected",
reason: Some(RejectReason::Timeout.as_str()),
decided_at: now,
},
);
self.push_notify(McpNotify::AgentActionDecided {
id,
decision: "rejected",
reason: Some(RejectReason::Timeout.as_str()),
});
}
}
pub fn attach_forge_warn_queue(
&mut self,
queue: std::sync::Arc<std::sync::Mutex<VecDeque<String>>>,
) {
self.forge_warn_queue = queue;
}
pub fn drain_forge_warnings(&mut self) {
let drained: Vec<String> = {
let Ok(mut q) = self.forge_warn_queue.lock() else {
return;
};
if q.is_empty() {
return;
}
q.drain(..).collect()
};
for msg in drained {
self.set_error(msg);
}
}
pub fn record_agent_ghost(&mut self, file_idx: usize) {
let Some(file) = self.diff_files.get(file_idx) else {
return;
};
let path = file.display_path_lossy().to_string_lossy().to_string();
self.agent_ghost = Some(AgentGhost { file_idx, path });
}
pub fn jump_to_agent_ghost(&mut self) -> bool {
let Some(ghost) = self.agent_ghost.take() else {
self.set_message("No agent ghost to jump to");
return false;
};
self.viewport_pinned = false;
if ghost.file_idx >= self.diff_files.len() {
self.set_warning("Agent ghost pointed at a file that's no longer in the diff");
return false;
}
self.jump_to_file(ghost.file_idx);
true
}
pub fn jump_to_bottom(&mut self) {
let total = self.line_annotations.len();
let mut target = total.saturating_sub(1);
while target > 0 {
if !matches!(
self.line_annotations.get(target),
Some(AnnotatedLine::Spacing)
) {
break;
}
target -= 1;
}
self.diff_state.cursor_line = target;
let viewport = self.diff_state.viewport_height.max(1);
self.diff_state.scroll_offset = total.saturating_sub(viewport);
self.update_current_file_from_cursor();
}
pub(super) fn file_idx_to_tree_idx(&self, target_file_idx: usize) -> Option<usize> {
let visible_items = self.build_visible_items();
for (tree_idx, item) in visible_items.iter().enumerate() {
if let FileTreeItem::File { file_idx, .. } = item
&& *file_idx == target_file_idx
{
return Some(tree_idx);
}
}
None
}
pub fn get_line_at_cursor(&self) -> Option<(u32, LineSide)> {
let target = self.diff_state.cursor_line;
match self.line_annotations.get(target) {
Some(
AnnotatedLine::DiffLine {
old_lineno,
new_lineno,
..
}
| AnnotatedLine::SideBySideLine {
old_lineno,
new_lineno,
..
},
) => {
new_lineno
.map(|ln| (ln, LineSide::New))
.or_else(|| old_lineno.map(|ln| (ln, LineSide::Old)))
}
_ => None,
}
}
pub fn current_line_content(&self) -> Option<String> {
let target = self.diff_state.cursor_line;
match self.line_annotations.get(target)? {
AnnotatedLine::DiffLine {
file_idx,
hunk_idx,
line_idx,
..
} => {
let file = self.diff_files.get(*file_idx)?;
let hunk = file.hunks.get(*hunk_idx)?;
let line = hunk.lines.get(*line_idx)?;
Some(line.content.clone())
}
AnnotatedLine::SideBySideLine {
file_idx,
hunk_idx,
add_line_idx,
del_line_idx,
..
} => {
let file = self.diff_files.get(*file_idx)?;
let hunk = file.hunks.get(*hunk_idx)?;
let idx = add_line_idx.or(*del_line_idx)?;
hunk.lines.get(idx).map(|l| l.content.clone())
}
_ => None,
}
}
pub fn line_content_at(&self, line: u32, side: LineSide) -> Option<String> {
let file = self.diff_files.get(self.diff_state.current_file_idx)?;
for hunk in &file.hunks {
for diff_line in &hunk.lines {
let matches = match side {
LineSide::New => diff_line.new_lineno == Some(line),
LineSide::Old => diff_line.old_lineno == Some(line),
};
if matches {
return Some(diff_line.content.clone());
}
}
}
None
}
pub fn line_content_range(&self, start: u32, end: u32, side: LineSide) -> Option<String> {
let file = self.diff_files.get(self.diff_state.current_file_idx)?;
let (lo, hi) = if start <= end {
(start, end)
} else {
(end, start)
};
let mut parts: Vec<String> = Vec::new();
for target in lo..=hi {
for hunk in &file.hunks {
for diff_line in &hunk.lines {
let matches = match side {
LineSide::New => diff_line.new_lineno == Some(target),
LineSide::Old => diff_line.old_lineno == Some(target),
};
if matches {
parts.push(diff_line.content.clone());
break;
}
}
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("\n"))
}
}
}
#[cfg(test)]
mod tree_tests {
use super::*;
use std::collections::HashSet;
use travelagent_core::model::{DiffFile, FileStatus};
fn make_file(path: &str) -> DiffFile {
DiffFile {
old_path: None,
new_path: Some(PathBuf::from(path)),
status: FileStatus::Modified,
hunks: vec![],
is_binary: false,
is_too_large: false,
is_commit_message: false,
}
}
struct TreeTestHarness {
diff_files: Vec<DiffFile>,
expanded_dirs: HashSet<String>,
}
impl TreeTestHarness {
fn new(paths: &[&str]) -> Self {
Self {
diff_files: paths.iter().map(|p| make_file(p)).collect(),
expanded_dirs: HashSet::new(),
}
}
fn expand_all(&mut self) {
use std::path::Path;
for file in &self.diff_files {
let path = file.display_path_lossy();
let mut current = path.parent();
while let Some(parent) = current {
if parent != Path::new("") {
self.expanded_dirs
.insert(parent.to_string_lossy().to_string());
}
current = parent.parent();
}
}
}
fn collapse_all(&mut self) {
self.expanded_dirs.clear();
}
fn toggle(&mut self, dir: &str) {
if self.expanded_dirs.contains(dir) {
self.expanded_dirs.remove(dir);
} else {
self.expanded_dirs.insert(dir.to_string());
}
}
fn build_visible_items(&self) -> Vec<FileTreeItem> {
use std::path::Path;
let mut items = Vec::new();
let mut seen_dirs: HashSet<String> = HashSet::new();
for (file_idx, file) in self.diff_files.iter().enumerate() {
let path = file.display_path_lossy();
let mut ancestors: Vec<String> = Vec::new();
let mut current = path.parent();
while let Some(parent) = current {
if parent != Path::new("") {
ancestors.push(parent.to_string_lossy().to_string());
}
current = parent.parent();
}
ancestors.reverse();
let mut visible = true;
for (depth, dir) in ancestors.iter().enumerate() {
if !seen_dirs.contains(dir) && visible {
let expanded = self.expanded_dirs.contains(dir);
items.push(FileTreeItem::Directory {
path: dir.clone(),
depth,
expanded,
});
seen_dirs.insert(dir.clone());
}
if !self.expanded_dirs.contains(dir) {
visible = false;
}
}
if visible {
items.push(FileTreeItem::File {
file_idx,
depth: ancestors.len(),
});
}
}
items
}
fn visible_file_count(&self) -> usize {
self.build_visible_items()
.iter()
.filter(|i| matches!(i, FileTreeItem::File { .. }))
.count()
}
fn visible_dir_count(&self) -> usize {
self.build_visible_items()
.iter()
.filter(|i| matches!(i, FileTreeItem::Directory { .. }))
.count()
}
}
#[test]
fn test_expand_all_shows_all_files() {
let mut h = TreeTestHarness::new(&["src/ui/app.rs", "src/ui/help.rs", "src/main.rs"]);
h.expand_all();
assert_eq!(h.visible_file_count(), 3);
}
#[test]
fn test_collapse_all_hides_all_files() {
let mut h = TreeTestHarness::new(&["src/ui/app.rs", "src/main.rs"]);
h.expand_all();
h.collapse_all();
assert_eq!(h.visible_file_count(), 0);
assert_eq!(h.visible_dir_count(), 1); }
#[test]
fn test_collapse_parent_hides_nested_dirs() {
let mut h = TreeTestHarness::new(&["src/ui/components/button.rs"]);
h.expand_all();
assert_eq!(h.visible_dir_count(), 3);
h.toggle("src");
let items = h.build_visible_items();
assert_eq!(items.len(), 1); assert!(matches!(
&items[0],
FileTreeItem::Directory {
expanded: false,
..
}
));
}
#[test]
fn test_root_files_always_visible() {
let mut h = TreeTestHarness::new(&["README.md", "Cargo.toml"]);
h.collapse_all();
assert_eq!(h.visible_file_count(), 2);
}
#[test]
fn test_tree_depth_correct() {
let mut h = TreeTestHarness::new(&["a/b/c/file.rs"]);
h.expand_all();
let items = h.build_visible_items();
assert!(matches!(&items[0], FileTreeItem::Directory { depth: 0, path, .. } if path == "a"));
assert!(
matches!(&items[1], FileTreeItem::Directory { depth: 1, path, .. } if path == "a/b")
);
assert!(
matches!(&items[2], FileTreeItem::Directory { depth: 2, path, .. } if path == "a/b/c")
);
assert!(matches!(&items[3], FileTreeItem::File { depth: 3, .. }));
}
#[test]
fn test_toggle_expands_collapsed_dir() {
let mut h = TreeTestHarness::new(&["src/main.rs"]);
h.collapse_all();
assert_eq!(h.visible_file_count(), 0);
h.toggle("src");
assert_eq!(h.visible_file_count(), 1);
}
#[test]
fn test_collapsed_dir_stays_in_visible_items() {
let mut h = TreeTestHarness::new(&["src/app.rs", "src/main.rs", "tests/test.rs"]);
h.expand_all();
let items_before = h.build_visible_items();
let src_idx_before = items_before
.iter()
.position(|item| matches!(item, FileTreeItem::Directory { path, .. } if path == "src"))
.expect("src dir should be visible");
h.toggle("src");
let items_after = h.build_visible_items();
let src_idx_after = items_after
.iter()
.position(|item| matches!(item, FileTreeItem::Directory { path, .. } if path == "src"))
.expect("src dir should still be visible after collapse");
assert_eq!(
src_idx_before, src_idx_after,
"collapsed directory should remain at the same position in the tree"
);
assert!(matches!(
&items_after[src_idx_after],
FileTreeItem::Directory {
expanded: false,
..
}
));
}
#[test]
fn test_sibling_dirs_independent() {
let mut h = TreeTestHarness::new(&["src/app.rs", "tests/test.rs"]);
h.expand_all();
h.toggle("src");
assert_eq!(h.visible_file_count(), 1); }
}
#[cfg(test)]
mod commit_selection_tests {
use super::*;
use chrono::Utc;
use std::path::Path;
use travelagent_core::error::{Result, TrvError};
use travelagent_core::model::{FileStatus, SessionDiffSource};
use travelagent_core::vcs::VcsType;
struct DummyVcs {
info: VcsInfo,
}
impl VcsBackend for DummyVcs {
fn info(&self) -> &VcsInfo {
&self.info
}
fn get_working_tree_diff(&self) -> Result<Vec<DiffFile>> {
Err(TrvError::NoChanges)
}
fn fetch_context_lines(
&self,
_file_path: &Path,
_file_status: FileStatus,
_start_line: u32,
_end_line: u32,
) -> Result<Vec<DiffLine>> {
Ok(Vec::new())
}
}
fn build_app(commit_list: Vec<CommitInfo>) -> App {
let vcs_info = VcsInfo {
root_path: PathBuf::from("/tmp"),
head_commit: "head".to_string(),
branch_name: Some("main".to_string()),
vcs_type: VcsType::Git,
};
let session = travelagent_core::model::ReviewSession::new(
vcs_info.root_path.clone(),
vcs_info.head_commit.clone(),
vcs_info.branch_name.clone(),
SessionDiffSource::WorkingTree,
);
App::build(
Box::new(DummyVcs {
info: vcs_info.clone(),
}),
vcs_info,
Theme::dark(),
None,
false,
Vec::new(),
session,
DiffSource::WorkingTree,
InputMode::CommitSelect,
commit_list,
None,
crate::test_support::runtime_handle(),
AppMode::Local(LocalState::default()),
)
.expect("failed to build test app")
}
fn normal_commit(id: &str) -> CommitInfo {
CommitInfo {
id: id.to_string(),
short_id: id.to_string(),
branch_name: None,
summary: "Test commit".to_string(),
body: None,
author: "Test".to_string(),
time: Utc::now(),
}
}
#[test]
fn special_commit_count_counts_leading_special_entries() {
let app = build_app(vec![
App::staged_commit_entry(),
App::unstaged_commit_entry(),
normal_commit("abc123"),
]);
assert_eq!(app.special_commit_count(), 2);
}
#[test]
fn special_commit_count_ignores_non_leading_special_entries() {
let app = build_app(vec![normal_commit("abc123"), App::staged_commit_entry()]);
assert_eq!(app.special_commit_count(), 0);
}
#[test]
fn confirm_commit_selection_with_only_staged_does_not_panic() {
let mut app = build_app(vec![App::staged_commit_entry()]);
app.commit_select.selection_range = Some((0, 0));
app.commit_select.cursor = 0;
let result = app.confirm_commit_selection();
match result {
Ok(()) => {} Err(TrvError::UnsupportedOperation(msg)) => {
assert!(
msg.contains("Staged"),
"expected staged-path error, got: {msg}"
);
}
Err(other) => panic!("unexpected error (should not panic on unwrap): {other:?}"),
}
}
}
#[cfg(test)]
mod tour_unwrap_tests {
use super::*;
use std::path::Path;
use travelagent_core::error::{Result, TrvError};
use travelagent_core::model::{FileStatus, SessionDiffSource, TourStop};
use travelagent_core::vcs::VcsType;
struct TourDummyVcs {
info: VcsInfo,
}
impl VcsBackend for TourDummyVcs {
fn info(&self) -> &VcsInfo {
&self.info
}
fn get_working_tree_diff(&self) -> Result<Vec<DiffFile>> {
Err(TrvError::NoChanges)
}
fn get_commit_range_diff(&self, _commit_ids: &[String]) -> Result<Vec<DiffFile>> {
Ok(Vec::new())
}
fn fetch_context_lines(
&self,
_file_path: &Path,
_file_status: FileStatus,
_start_line: u32,
_end_line: u32,
) -> Result<Vec<DiffLine>> {
Ok(Vec::new())
}
}
fn build_tour_app() -> App {
let vcs_info = VcsInfo {
root_path: PathBuf::from("/tmp"),
head_commit: "head".to_string(),
branch_name: Some("main".to_string()),
vcs_type: VcsType::Git,
};
let session = travelagent_core::model::ReviewSession::new(
vcs_info.root_path.clone(),
vcs_info.head_commit.clone(),
vcs_info.branch_name.clone(),
SessionDiffSource::WorkingTree,
);
App::build(
Box::new(TourDummyVcs {
info: vcs_info.clone(),
}),
vcs_info,
Theme::dark(),
None,
false,
Vec::new(),
session,
DiffSource::WorkingTree,
InputMode::Normal,
Vec::new(),
None,
crate::test_support::runtime_handle(),
AppMode::Local(LocalState::default()),
)
.expect("failed to build test app")
}
#[test]
fn tour_start_with_stops_sets_tour_and_does_not_panic() {
let mut app = build_tour_app();
let stops = vec![TourStop {
commit_ids: vec!["abc".into()],
summary: "only stop".into(),
risk: travelagent_core::risk::RiskScore::MIN,
}];
let result = app.tour_start(stops);
assert!(result.is_ok(), "tour_start should succeed: {result:?}");
assert!(app.tour.plan.is_some());
assert_eq!(app.tour.plan.as_ref().unwrap().index, 0);
}
#[test]
fn tour_start_called_twice_does_not_panic() {
let mut app = build_tour_app();
let stops_a = vec![TourStop {
commit_ids: vec!["aaa".into()],
summary: "first".into(),
risk: travelagent_core::risk::RiskScore::MIN,
}];
let stops_b = vec![TourStop {
commit_ids: vec!["bbb".into()],
summary: "second".into(),
risk: travelagent_core::risk::RiskScore::MIN,
}];
app.tour_start(stops_a).unwrap();
let result = app.tour_start(stops_b);
assert!(result.is_ok());
let tour = app.tour.plan.as_ref().unwrap();
assert_eq!(tour.stops.len(), 1);
assert_eq!(tour.stops[0].summary, "second");
}
#[test]
fn tour_goto_when_no_tour_returns_error_not_panic() {
let mut app = build_tour_app();
let result = app.tour_goto(0);
assert!(result.is_err(), "tour_goto without tour should error");
}
#[test]
fn tour_goto_valid_index_succeeds() {
let mut app = build_tour_app();
let stops = vec![
TourStop {
commit_ids: vec!["aaa".into()],
summary: "first".into(),
risk: travelagent_core::risk::RiskScore::MIN,
},
TourStop {
commit_ids: vec!["bbb".into()],
summary: "second".into(),
risk: travelagent_core::risk::RiskScore::MIN,
},
];
app.tour_start(stops).unwrap();
let result = app.tour_goto(1);
assert!(result.is_ok());
assert_eq!(app.tour.plan.as_ref().unwrap().index, 1);
}
}
#[cfg(test)]
mod merge_method_tests {
use super::*;
use travelagent_core::error::TrvError;
use travelagent_core::forge::MergeMethod;
#[test]
fn pick_merge_method_prefers_squash_when_allowed() {
let allowed = vec![MergeMethod::Squash, MergeMethod::Merge, MergeMethod::Rebase];
assert_eq!(
App::pick_merge_method(&allowed).unwrap(),
MergeMethod::Squash
);
}
#[test]
fn pick_merge_method_falls_through_to_merge_when_no_squash() {
let allowed = vec![MergeMethod::Merge, MergeMethod::Rebase];
assert_eq!(
App::pick_merge_method(&allowed).unwrap(),
MergeMethod::Merge
);
}
#[test]
fn pick_merge_method_falls_through_to_rebase_as_last_resort() {
let allowed = vec![MergeMethod::Rebase];
assert_eq!(
App::pick_merge_method(&allowed).unwrap(),
MergeMethod::Rebase
);
}
#[test]
fn pick_merge_method_respects_squash_only_repos() {
let allowed = vec![MergeMethod::Squash];
assert_eq!(
App::pick_merge_method(&allowed).unwrap(),
MergeMethod::Squash
);
}
#[test]
fn pick_merge_method_errors_when_none_allowed() {
let allowed: Vec<MergeMethod> = vec![];
let result = App::pick_merge_method(&allowed);
assert!(matches!(result, Err(TrvError::UnsupportedOperation(_))));
}
}
#[cfg(test)]
mod scroll_tests {
use super::*;
fn calc_max_scroll(total_lines: usize, viewport_height: usize, wrap_lines: bool) -> usize {
let viewport = viewport_height.max(1);
if wrap_lines {
total_lines.saturating_sub(1)
} else {
total_lines.saturating_sub(viewport)
}
}
#[test]
fn should_calculate_max_scroll_without_wrapping() {
let total = 103;
let viewport = 20;
let max_scroll = calc_max_scroll(total, viewport, false);
assert_eq!(max_scroll, 83); }
#[test]
fn should_calculate_max_scroll_with_wrapping() {
let total = 103;
let viewport = 20;
let max_scroll = calc_max_scroll(total, viewport, true);
assert_eq!(max_scroll, 102); }
#[test]
fn should_allow_scrolling_further_with_wrapping() {
let total = 103;
let viewport = 20;
let max_no_wrap = calc_max_scroll(total, viewport, false);
let max_with_wrap = calc_max_scroll(total, viewport, true);
assert!(
max_with_wrap > max_no_wrap,
"With wrapping, max_scroll ({max_with_wrap}) should be greater than without ({max_no_wrap})"
);
assert_eq!(max_with_wrap - max_no_wrap, viewport - 1);
}
#[test]
fn should_handle_small_content_without_wrapping() {
let total = 13;
let viewport = 50;
let max_scroll = calc_max_scroll(total, viewport, false);
assert_eq!(max_scroll, 0);
}
#[test]
fn should_handle_small_content_with_wrapping() {
let total = 13;
let viewport = 50;
let max_scroll = calc_max_scroll(total, viewport, true);
assert_eq!(max_scroll, 12); }
#[test]
fn should_handle_empty_content() {
let total = 0;
let viewport = 20;
let max_scroll_no_wrap = calc_max_scroll(total, viewport, false);
let max_scroll_wrap = calc_max_scroll(total, viewport, true);
assert_eq!(max_scroll_no_wrap, 0);
assert_eq!(max_scroll_wrap, 0);
}
#[test]
fn should_handle_zero_viewport() {
let total = 100;
let viewport = 0;
let max_scroll_no_wrap = calc_max_scroll(total, viewport, false);
let max_scroll_wrap = calc_max_scroll(total, viewport, true);
assert_eq!(max_scroll_no_wrap, 99); assert_eq!(max_scroll_wrap, 99); }
#[test]
fn should_match_max_scroll_offset_implementation() {
let diff_state_no_wrap = DiffState {
viewport_height: 20,
wrap_lines: false,
..Default::default()
};
let diff_state_wrap = DiffState {
viewport_height: 20,
wrap_lines: true,
..Default::default()
};
assert!(!diff_state_no_wrap.wrap_lines);
assert!(diff_state_wrap.wrap_lines);
assert_eq!(diff_state_no_wrap.viewport_height, 20);
assert_eq!(diff_state_wrap.viewport_height, 20);
}
}
#[cfg(test)]
mod find_source_line_tests {
use super::*;
fn make_diff_line(file_idx: usize, new_lineno: Option<u32>) -> AnnotatedLine {
AnnotatedLine::DiffLine {
file_idx,
hunk_idx: 0,
line_idx: 0,
old_lineno: None,
new_lineno,
}
}
fn make_sbs_line(file_idx: usize, new_lineno: Option<u32>) -> AnnotatedLine {
AnnotatedLine::SideBySideLine {
file_idx,
hunk_idx: 0,
del_line_idx: None,
add_line_idx: None,
old_lineno: None,
new_lineno,
}
}
#[test]
fn should_find_exact_match() {
let annotations = vec![
AnnotatedLine::FileHeader { file_idx: 0 },
make_diff_line(0, Some(10)),
make_diff_line(0, Some(11)),
make_diff_line(0, Some(12)),
];
let result = find_source_line(&annotations, 0, 11);
assert_eq!(result, FindSourceLineResult::Exact(2));
}
#[test]
fn should_find_nearest_when_no_exact_match() {
let annotations = vec![
make_diff_line(0, Some(10)),
make_diff_line(0, Some(15)),
make_diff_line(0, Some(20)),
];
let result = find_source_line(&annotations, 0, 12);
assert_eq!(result, FindSourceLineResult::Nearest(0));
}
#[test]
fn should_find_nearest_above_target() {
let annotations = vec![
make_diff_line(0, Some(10)),
make_diff_line(0, Some(15)),
make_diff_line(0, Some(20)),
];
let result = find_source_line(&annotations, 0, 18);
assert_eq!(result, FindSourceLineResult::Nearest(2));
}
#[test]
fn should_return_not_found_for_empty_annotations() {
let annotations: Vec<AnnotatedLine> = vec![];
let result = find_source_line(&annotations, 0, 42);
assert_eq!(result, FindSourceLineResult::NotFound);
}
#[test]
fn should_return_not_found_when_no_lines_in_current_file() {
let annotations = vec![make_diff_line(1, Some(10)), make_diff_line(1, Some(20))];
let result = find_source_line(&annotations, 0, 10);
assert_eq!(result, FindSourceLineResult::NotFound);
}
#[test]
fn should_skip_lines_from_other_files() {
let annotations = vec![
make_diff_line(0, Some(100)), make_diff_line(1, Some(42)), make_diff_line(0, Some(50)), ];
let result = find_source_line(&annotations, 0, 42);
assert_eq!(result, FindSourceLineResult::Nearest(2));
}
#[test]
fn should_skip_non_diff_line_annotations() {
let annotations = vec![
AnnotatedLine::FileHeader { file_idx: 0 },
AnnotatedLine::HunkHeader {
file_idx: 0,
hunk_idx: 0,
},
AnnotatedLine::Spacing,
make_diff_line(0, Some(42)),
];
let result = find_source_line(&annotations, 0, 42);
assert_eq!(result, FindSourceLineResult::Exact(3));
}
#[test]
fn should_skip_diff_lines_with_no_new_lineno() {
let annotations = vec![make_diff_line(0, None), make_diff_line(0, Some(20))];
let result = find_source_line(&annotations, 0, 5);
assert_eq!(result, FindSourceLineResult::Nearest(1));
}
#[test]
fn should_work_with_side_by_side_lines() {
let annotations = vec![
make_sbs_line(0, Some(10)),
make_sbs_line(0, Some(20)),
make_sbs_line(0, Some(30)),
];
let result = find_source_line(&annotations, 0, 20);
assert_eq!(result, FindSourceLineResult::Exact(1));
}
#[test]
fn should_handle_mixed_diff_and_sbs_lines() {
let annotations = vec![
make_diff_line(0, Some(10)),
make_sbs_line(0, Some(20)),
make_diff_line(0, Some(30)),
];
let result = find_source_line(&annotations, 0, 25);
assert_eq!(result, FindSourceLineResult::Nearest(1));
}
#[test]
fn should_return_not_found_when_only_non_line_annotations() {
let annotations = vec![
AnnotatedLine::FileHeader { file_idx: 0 },
AnnotatedLine::Spacing,
AnnotatedLine::HunkHeader {
file_idx: 0,
hunk_idx: 0,
},
];
let result = find_source_line(&annotations, 0, 42);
assert_eq!(result, FindSourceLineResult::NotFound);
}
#[test]
fn should_prefer_exact_match_over_earlier_nearest() {
let annotations = vec![
make_diff_line(0, Some(41)), make_diff_line(0, Some(42)), make_diff_line(0, Some(43)), ];
let result = find_source_line(&annotations, 0, 42);
assert_eq!(result, FindSourceLineResult::Exact(1));
}
#[test]
fn should_find_nearest_for_target_zero() {
let annotations = vec![make_diff_line(0, Some(1)), make_diff_line(0, Some(5))];
let result = find_source_line(&annotations, 0, 0);
assert_eq!(result, FindSourceLineResult::Nearest(0));
}
#[test]
fn should_tie_break_nearest_by_iteration_order() {
let annotations = vec![
make_diff_line(0, Some(30)),
make_diff_line(0, Some(50)),
make_diff_line(0, Some(10)),
];
let result = find_source_line(&annotations, 0, 20);
assert_eq!(result, FindSourceLineResult::Nearest(0));
}
}
#[cfg(test)]
mod expand_gap_tests {
use super::*;
use std::path::Path;
use travelagent_core::error::{Result, TrvError};
use travelagent_core::model::{DiffHunk, DiffLine, FileStatus, LineOrigin, SessionDiffSource};
use travelagent_core::vcs::VcsType;
struct MockVcs {
info: VcsInfo,
total_lines: u32,
}
impl VcsBackend for MockVcs {
fn info(&self) -> &VcsInfo {
&self.info
}
fn get_working_tree_diff(&self) -> Result<Vec<DiffFile>> {
Err(TrvError::NoChanges)
}
fn fetch_context_lines(
&self,
_file_path: &Path,
_file_status: FileStatus,
start_line: u32,
end_line: u32,
) -> Result<Vec<DiffLine>> {
let mut result = Vec::new();
for line_num in start_line..=end_line.min(self.total_lines) {
result.push(DiffLine {
origin: LineOrigin::Context,
content: format!("line {line_num}"),
old_lineno: Some(line_num),
new_lineno: Some(line_num),
highlighted_spans: None,
});
}
Ok(result)
}
}
fn make_hunk(new_start: u32, new_count: u32) -> DiffHunk {
let mut lines = Vec::new();
for i in 0..new_count {
lines.push(DiffLine {
origin: LineOrigin::Context,
content: format!("hunk line {}", new_start + i),
old_lineno: Some(new_start + i),
new_lineno: Some(new_start + i),
highlighted_spans: None,
});
}
DiffHunk {
header: format!("@@ -{new_start},{new_count} +{new_start},{new_count} @@"),
lines,
old_start: new_start,
old_count: new_count,
new_start,
new_count,
}
}
fn build_app_with_files(files: Vec<DiffFile>, total_lines: u32) -> App {
let vcs_info = VcsInfo {
root_path: PathBuf::from("/tmp"),
head_commit: "abc123".to_string(),
branch_name: Some("main".to_string()),
vcs_type: VcsType::Git,
};
let session = travelagent_core::model::ReviewSession::new(
vcs_info.root_path.clone(),
vcs_info.head_commit.clone(),
vcs_info.branch_name.clone(),
SessionDiffSource::WorkingTree,
);
App::build(
Box::new(MockVcs {
info: vcs_info.clone(),
total_lines,
}),
vcs_info,
Theme::dark(),
None,
false,
files,
session,
DiffSource::WorkingTree,
InputMode::Normal,
Vec::new(),
None,
crate::test_support::runtime_handle(),
AppMode::Local(LocalState::default()),
)
.expect("failed to build test app")
}
fn make_file_with_hunks(path: &str, hunks: Vec<DiffHunk>) -> DiffFile {
DiffFile {
old_path: None,
new_path: Some(PathBuf::from(path)),
status: FileStatus::Modified,
hunks,
is_binary: false,
is_too_large: false,
is_commit_message: false,
}
}
#[test]
fn should_expand_up_from_first_hunk() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(51, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 0,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(20))
.unwrap();
let content = app.gaps.expanded_bottom.get(&gap_id).unwrap();
assert_eq!(content.len(), 20);
assert_eq!(content[0].new_lineno, Some(31));
assert_eq!(content[19].new_lineno, Some(50));
}
#[test]
fn should_expand_all_lines_with_both_direction() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(51, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 0,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Both, None)
.unwrap();
let content = app.gaps.expanded_top.get(&gap_id).unwrap();
assert_eq!(content.len(), 50);
assert_eq!(content[0].new_lineno, Some(1));
assert_eq!(content[49].new_lineno, Some(50));
}
#[test]
fn should_expand_down_from_upper_hunk() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(1, 5), make_hunk(30, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 1,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Down, Some(10))
.unwrap();
let content = app.gaps.expanded_top.get(&gap_id).unwrap();
assert_eq!(content.len(), 10);
assert_eq!(content[0].new_lineno, Some(6));
assert_eq!(content[9].new_lineno, Some(15));
}
#[test]
fn should_expand_up_from_lower_hunk() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(1, 5), make_hunk(30, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 1,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(10))
.unwrap();
let content = app.gaps.expanded_bottom.get(&gap_id).unwrap();
assert_eq!(content.len(), 10);
assert_eq!(content[0].new_lineno, Some(20));
assert_eq!(content[9].new_lineno, Some(29));
}
#[test]
fn should_append_on_subsequent_down_expand() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(1, 5), make_hunk(50, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 1,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Down, Some(20))
.unwrap();
app.expand_gap(gap_id.clone(), ExpandDirection::Down, Some(20))
.unwrap();
let content = app.gaps.expanded_top.get(&gap_id).unwrap();
assert_eq!(content.len(), 40);
assert_eq!(content[0].new_lineno, Some(6));
assert_eq!(content[39].new_lineno, Some(45));
}
#[test]
fn should_prepend_on_subsequent_up_expand() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(1, 5), make_hunk(50, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 1,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(10))
.unwrap();
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(10))
.unwrap();
let content = app.gaps.expanded_bottom.get(&gap_id).unwrap();
assert_eq!(content.len(), 20);
assert_eq!(content[0].new_lineno, Some(30));
assert_eq!(content[19].new_lineno, Some(49));
}
#[test]
fn should_cap_at_gap_boundaries() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(51, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 0,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(40))
.unwrap();
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(20))
.unwrap();
let content = app.gaps.expanded_bottom.get(&gap_id).unwrap();
assert_eq!(content.len(), 50);
assert_eq!(content[0].new_lineno, Some(1));
}
#[test]
fn should_show_up_expander_for_top_of_file_partial() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(51, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 0,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(20))
.unwrap();
let expander_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::Expander { gap_id: g, direction: ExpandDirection::Up } if *g == gap_id))
.count();
assert_eq!(expander_count, 1);
let hidden_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::HiddenLines { gap_id: g, .. } if *g == gap_id))
.count();
assert_eq!(hidden_count, 1, "should show hidden lines count");
let expanded_count = app
.line_annotations
.iter()
.filter(
|a| matches!(a, AnnotatedLine::ExpandedContext { gap_id: g, .. } if *g == gap_id),
)
.count();
assert_eq!(expanded_count, 20);
}
#[test]
fn should_not_show_expander_when_fully_expanded() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(51, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 0,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Both, None)
.unwrap();
let expander_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::Expander { gap_id: g, .. } if *g == gap_id))
.count();
assert_eq!(expander_count, 0);
}
#[test]
fn should_show_merged_expander_for_small_between_hunk_gap() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(1, 5), make_hunk(21, 5)]);
let app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 1,
};
let both_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::Expander { gap_id: g, direction: ExpandDirection::Both } if *g == gap_id))
.count();
assert_eq!(both_count, 1, "small gap should show merged ↕ expander");
}
#[test]
fn should_show_split_expanders_for_large_between_hunk_gap() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(1, 5), make_hunk(36, 5)]);
let app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 1,
};
let down_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::Expander { gap_id: g, direction: ExpandDirection::Down } if *g == gap_id))
.count();
let up_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::Expander { gap_id: g, direction: ExpandDirection::Up } if *g == gap_id))
.count();
let hidden_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::HiddenLines { gap_id: g, .. } if *g == gap_id))
.count();
assert_eq!(down_count, 1);
assert_eq!(up_count, 1);
assert_eq!(hidden_count, 1);
}
#[test]
fn should_expand_gap_in_correct_file_not_adjacent_file() {
let file0 = make_file_with_hunks("a.rs", vec![make_hunk(31, 5)]);
let file1 = make_file_with_hunks("b.rs", vec![make_hunk(21, 5)]);
let mut app = build_app_with_files(vec![file0, file1], 100);
let gap_id_file1 = GapId {
file_idx: 1,
hunk_idx: 0,
};
app.expand_gap(gap_id_file1.clone(), ExpandDirection::Up, Some(10))
.unwrap();
let content = app.gaps.expanded_bottom.get(&gap_id_file1).unwrap();
assert_eq!(content.len(), 10);
assert_eq!(content[9].new_lineno, Some(20));
let gap_id_file0 = GapId {
file_idx: 0,
hunk_idx: 0,
};
assert!(
!app.gaps.expanded_top.contains_key(&gap_id_file0)
&& !app.gaps.expanded_bottom.contains_key(&gap_id_file0)
);
}
#[test]
fn should_noop_when_already_fully_expanded() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(11, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 0,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Both, None)
.unwrap();
let len_before = app.gaps.expanded_top.get(&gap_id).unwrap().len();
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(20))
.unwrap();
let len_after = app.gaps.expanded_top.get(&gap_id).unwrap().len();
assert_eq!(len_before, len_after);
}
#[test]
fn should_noop_when_limit_is_zero_up() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(51, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 0,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(0))
.unwrap();
assert!(!app.gaps.expanded_bottom.contains_key(&gap_id));
assert!(!app.gaps.expanded_top.contains_key(&gap_id));
}
#[test]
fn should_noop_when_limit_is_zero_down() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(1, 5), make_hunk(30, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 1,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Down, Some(0))
.unwrap();
assert!(!app.gaps.expanded_top.contains_key(&gap_id));
assert!(!app.gaps.expanded_bottom.contains_key(&gap_id));
}
#[test]
fn should_expand_small_gap_fully_even_with_large_limit() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(6, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 0,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(20))
.unwrap();
let content = app.gaps.expanded_bottom.get(&gap_id).unwrap();
assert_eq!(content.len(), 5);
let expander_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::Expander { gap_id: g, .. } if *g == gap_id))
.count();
assert_eq!(expander_count, 0);
}
#[test]
fn should_merge_to_both_when_remaining_drops_below_batch() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(1, 5), make_hunk(36, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 1,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Down, Some(20))
.unwrap();
let both_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::Expander { gap_id: g, direction: ExpandDirection::Both } if *g == gap_id))
.count();
assert_eq!(both_count, 1, "should merge to ↕ when <20 remaining");
}
}
#[cfg(test)]
mod refresh_remote_tests {
use super::*;
use async_trait::async_trait;
use travelagent_core::error::Result as CoreResult;
use travelagent_core::forge::{
ForgeComments, ForgeMerge, ForgeReactions, ForgeRead, ForgeReview, ForgeType, MergeMethod,
MergeableStatus, NewComment, NewReview, Permissions, PrId, PrListFilter, PrListItem,
PrMetadata, PrState, ReactionContent, ReactionTarget, RemoteComment, ReviewThread, User,
};
use travelagent_core::model::DiffFile;
use travelagent_core::vcs::CommitInfo;
struct MockForge {
metadata: PrMetadata,
files: Vec<DiffFile>,
commits: Vec<CommitInfo>,
comments: Vec<RemoteComment>,
threads: Vec<ReviewThread>,
}
#[async_trait]
impl ForgeRead for MockForge {
fn forge_type(&self) -> ForgeType {
ForgeType::GitHub
}
async fn get_pr(&self, _id: &PrId) -> CoreResult<PrMetadata> {
Ok(self.metadata.clone())
}
async fn get_pr_commits(&self, _id: &PrId) -> CoreResult<Vec<CommitInfo>> {
Ok(self.commits.clone())
}
async fn get_pr_files(&self, _id: &PrId) -> CoreResult<Vec<DiffFile>> {
Ok(self.files.clone())
}
async fn get_commit_diff(
&self,
_id: &PrId,
_commit_sha: &str,
) -> CoreResult<Vec<DiffFile>> {
unimplemented!("not used by refresh_remote")
}
async fn list_prs(
&self,
_owner: &str,
_repo: &str,
_filter: &PrListFilter,
) -> CoreResult<Vec<PrListItem>> {
unimplemented!("not used by refresh_remote / forge_required tests")
}
async fn current_user(&self) -> CoreResult<User> {
unimplemented!()
}
async fn check_permissions(&self, _id: &PrId) -> CoreResult<Permissions> {
unimplemented!()
}
}
#[async_trait]
impl ForgeComments for MockForge {
async fn get_comments(&self, _id: &PrId) -> CoreResult<Vec<RemoteComment>> {
Ok(self.comments.clone())
}
async fn get_review_threads(&self, _id: &PrId) -> CoreResult<Vec<ReviewThread>> {
Ok(self.threads.clone())
}
async fn post_comment(
&self,
_id: &PrId,
_comment: NewComment,
) -> CoreResult<RemoteComment> {
unimplemented!()
}
async fn post_reply(
&self,
_id: &PrId,
_thread_id: &str,
_body: &str,
) -> CoreResult<RemoteComment> {
unimplemented!()
}
async fn edit_comment(
&self,
_id: &PrId,
_comment_id: u64,
_body: &str,
) -> CoreResult<RemoteComment> {
unimplemented!()
}
async fn delete_comment(&self, _id: &PrId, _comment_id: u64) -> CoreResult<()> {
unimplemented!()
}
async fn resolve_thread(&self, _thread_id: &str) -> CoreResult<()> {
unimplemented!()
}
async fn unresolve_thread(&self, _thread_id: &str) -> CoreResult<()> {
unimplemented!()
}
}
#[async_trait]
impl ForgeReview for MockForge {
async fn submit_review(&self, _id: &PrId, _review: NewReview) -> CoreResult<()> {
unimplemented!()
}
}
#[async_trait]
impl ForgeMerge for MockForge {
async fn merge(&self, _id: &PrId, _method: MergeMethod) -> CoreResult<()> {
unimplemented!()
}
async fn close(&self, _id: &PrId) -> CoreResult<()> {
unimplemented!()
}
async fn reopen(&self, _id: &PrId) -> CoreResult<()> {
unimplemented!()
}
}
#[async_trait]
impl ForgeReactions for MockForge {
async fn add_reaction(
&self,
_target: &ReactionTarget,
_content: ReactionContent,
) -> CoreResult<()> {
unimplemented!()
}
async fn remove_reaction(
&self,
_target: &ReactionTarget,
_content: ReactionContent,
) -> CoreResult<()> {
unimplemented!()
}
}
fn meta(title: &str, sha: &str) -> PrMetadata {
PrMetadata {
title: title.to_string(),
body: String::new(),
author: "tester".to_string(),
state: PrState::Open,
base_branch: "main".to_string(),
head_branch: "feature".to_string(),
head_sha: sha.to_string(),
created_at: chrono::Utc::now(),
mergeable: Some(MergeableStatus::Clean),
is_draft: false,
}
}
#[test]
fn forge_required_returns_false_and_sets_warning_when_no_forge() {
let mut app = App::new_remote(
crate::theme::Theme::dark(),
None,
false,
Vec::new(),
"title".to_string(),
7,
"owner",
"repo",
crate::test_support::runtime_handle(),
None,
PrId {
owner: "owner".to_string(),
repo: "repo".to_string(),
number: 7,
},
)
.expect("new_remote");
assert!(
app.remote().expect("remote mode").forge.is_none(),
"precondition: no forge wired"
);
let ok = app.forge_required("post a comment");
assert!(!ok, "guard must return false when no forge is wired");
let msg = app.message.as_ref().expect("warning message set");
assert_eq!(msg.message_type, MessageType::Warning);
assert!(
msg.content.contains("post a comment"),
"warning should include the action verb, got {:?}",
msg.content
);
}
#[test]
fn forge_required_returns_true_and_preserves_message_when_forge_present() {
let mut app = App::new_remote(
crate::theme::Theme::dark(),
None,
false,
Vec::new(),
"title".to_string(),
7,
"owner",
"repo",
crate::test_support::runtime_handle(),
Some(std::sync::Arc::new(MockForge {
metadata: meta("title", "sha"),
files: Vec::new(),
commits: Vec::new(),
comments: Vec::new(),
threads: Vec::new(),
})),
PrId {
owner: "owner".to_string(),
repo: "repo".to_string(),
number: 7,
},
)
.expect("new_remote");
app.set_message("pre-existing info");
let ok = app.forge_required("anything");
assert!(ok, "guard must return true when forge is attached");
let msg = app
.message
.as_ref()
.expect("existing info message preserved");
assert_eq!(msg.message_type, MessageType::Info);
assert_eq!(msg.content, "pre-existing info");
}
#[test]
fn refresh_remote_replaces_metadata_and_stamps_time() {
let theme = crate::theme::Theme::dark();
let initial_meta = meta("Old title", "sha-initial");
let mut app = App::new_remote(
theme,
None,
false,
Vec::new(),
"Old title".to_string(),
42,
"owner",
"repo",
crate::test_support::runtime_handle(),
Some(std::sync::Arc::new(MockForge {
metadata: meta("New title", "sha-refreshed"),
files: Vec::new(),
commits: vec![CommitInfo {
id: "c1".to_string(),
short_id: "c1".to_string(),
branch_name: None,
summary: "Refreshed commit".to_string(),
body: None,
author: "tester".to_string(),
time: chrono::Utc::now(),
}],
comments: Vec::new(),
threads: Vec::new(),
})),
PrId {
owner: "owner".to_string(),
repo: "repo".to_string(),
number: 42,
},
)
.expect("new_remote");
{
let r = app.remote_mut().expect("remote mode");
r.pr_metadata = Some(initial_meta.clone());
r.last_refreshed_at = None;
}
let before_commits = app.remote().unwrap().pr_commits.len();
app.refresh_remote().expect("refresh_remote");
let r = app.remote().expect("remote mode");
let new_meta = r.pr_metadata.as_ref().expect("metadata present");
assert_eq!(new_meta.title, "New title");
assert_eq!(new_meta.head_sha, "sha-refreshed");
assert_eq!(r.pr_commits.len(), before_commits + 1);
assert!(r.last_refreshed_at.is_some());
}
#[test]
fn drain_forge_warnings_pushes_into_error_log() {
let mut app = App::new_remote(
crate::theme::Theme::dark(),
None,
false,
Vec::new(),
"title".to_string(),
7,
"owner",
"repo",
crate::test_support::runtime_handle(),
None,
PrId {
owner: "owner".to_string(),
repo: "repo".to_string(),
number: 7,
},
)
.expect("new_remote");
app.forge_warn_queue
.lock()
.unwrap()
.push_back("first warning".to_string());
app.forge_warn_queue
.lock()
.unwrap()
.push_back("second warning".to_string());
app.drain_forge_warnings();
assert!(app.forge_warn_queue.lock().unwrap().is_empty());
let msg = app.message.as_ref().expect("status message set");
assert_eq!(msg.message_type, MessageType::Error);
assert_eq!(msg.content, "second warning");
let history: Vec<String> = app
.error_log
.iter_newest_first()
.map(|m| m.content.clone())
.collect();
assert!(
history.iter().any(|c| c == "first warning"),
"first warning preserved in error_log ring, got {history:?}"
);
assert!(
history.iter().any(|c| c == "second warning"),
"second warning preserved in error_log ring, got {history:?}"
);
}
#[test]
fn drain_forge_warnings_is_noop_when_empty() {
let mut app = App::new_remote(
crate::theme::Theme::dark(),
None,
false,
Vec::new(),
"title".to_string(),
7,
"owner",
"repo",
crate::test_support::runtime_handle(),
None,
PrId {
owner: "owner".to_string(),
repo: "repo".to_string(),
number: 7,
},
)
.expect("new_remote");
app.set_message("pre-existing info");
app.drain_forge_warnings();
let msg = app.message.as_ref().expect("message preserved");
assert_eq!(msg.content, "pre-existing info");
}
}
#[cfg(test)]
mod jump_to_bottom_tests {
use super::*;
#[test]
fn jump_to_bottom_lands_on_last_content_line_not_spacing() {
let annotations = [
AnnotatedLine::ReviewCommentsHeader,
AnnotatedLine::FileHeader { file_idx: 0 },
AnnotatedLine::HunkHeader {
file_idx: 0,
hunk_idx: 0,
},
AnnotatedLine::DiffLine {
file_idx: 0,
hunk_idx: 0,
line_idx: 0,
old_lineno: None,
new_lineno: Some(1),
},
AnnotatedLine::DiffLine {
file_idx: 0,
hunk_idx: 0,
line_idx: 1,
old_lineno: None,
new_lineno: Some(2),
},
AnnotatedLine::Spacing, ];
let total = annotations.len();
let mut target = total.saturating_sub(1);
while target > 0 {
if !matches!(annotations.get(target), Some(AnnotatedLine::Spacing)) {
break;
}
target -= 1;
}
assert_eq!(target, 4);
assert!(matches!(
annotations[target],
AnnotatedLine::DiffLine {
new_lineno: Some(2),
..
}
));
}
}