use anyhow::Result;
use smallvec::SmallVec;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::task::AbortHandle;
use crate::ai::orchestrator::{OrchestratorCommand, RallyEvent};
use crate::ai::prompt_loader::PromptLoader;
use crate::ai::Context as AiContext;
use crate::cache::SessionCache;
use crate::config::Config;
use crate::filter::ListFilter;
use crate::github::comment::{DiscussionComment, ReviewComment};
use crate::github::{self, PrStateFilter, PullRequestSummary};
use crate::keybinding::KeyBinding;
use crate::loader::{CommentSubmitResult, DataLoadResult, SingleFileDiffResult};
use crate::ui;
use crate::ui::text_area::TextArea;
use std::time::Instant;
mod types;
pub use types::{
AiRallyState, AppState, CachedDiffLine, CommentPosition, CommentTab, DataState, DiffCache,
HelpTab, InternedSpan, InputMode, JumpLocation, LineInputContext, LogEntry, LogEventType,
MultilineSelection, PauseState, PermissionInfo, RefreshRequest, ReviewAction,
SymbolPopupState, ViewSnapshot, WatcherHandle, hash_string,
};
use types::MarkViewedResult;
mod polling;
mod input;
mod input_diff;
mod input_text;
mod comments;
mod diff_cache;
mod ai_rally;
mod key_sequence;
mod filter;
mod pr_list;
mod local_mode;
mod symbol;
#[cfg(test)]
mod tests;
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const MAX_HIGHLIGHTED_CACHE_ENTRIES: usize = 50;
const MAX_PREFETCH_FILES: usize = 50;
type PrReceiver<T> = Option<(u32, mpsc::Receiver<T>)>;
pub struct App {
pub repo: String,
pub pr_number: Option<u32>,
pub data_state: DataState,
pub state: AppState,
pub pr_list: Option<Vec<PullRequestSummary>>,
pub selected_pr: usize,
pub pr_list_scroll_offset: usize,
pub pr_list_loading: bool,
pub pr_list_has_more: bool,
pub pr_list_state_filter: PrStateFilter,
pub started_from_pr_list: bool,
local_mode: bool,
local_auto_focus: bool,
local_file_signatures: HashMap<String, u64>,
local_file_patch_signatures: HashMap<String, u64>,
original_pr_number: Option<u32>,
saved_pr_snapshot: Option<ViewSnapshot>,
saved_local_snapshot: Option<ViewSnapshot>,
watcher_handle: Option<WatcherHandle>,
refresh_pending: Option<Arc<AtomicBool>>,
pr_list_receiver: Option<mpsc::Receiver<Result<github::PrListPage, String>>>,
pub diff_view_return_state: AppState,
pub preview_return_state: AppState,
pub previous_state: AppState,
pub selected_file: usize,
pub file_list_scroll_offset: usize,
pub selected_line: usize,
pub diff_line_count: usize,
pub scroll_offset: usize,
pub multiline_selection: Option<MultilineSelection>,
pub input_mode: Option<InputMode>,
pub input_text_area: TextArea,
pub config: Config,
pub should_quit: bool,
pub review_comments: Option<Vec<ReviewComment>>,
pub selected_comment: usize,
pub comment_list_scroll_offset: usize,
pub comments_loading: bool,
pub file_comment_positions: Vec<CommentPosition>,
pub file_comment_lines: HashSet<usize>,
pub comment_panel_open: bool,
pub comment_panel_scroll: u16,
pub diff_cache: Option<DiffCache>,
highlighted_cache_store: HashMap<usize, DiffCache>,
pub discussion_comments: Option<Vec<DiscussionComment>>,
pub selected_discussion_comment: usize,
pub discussion_comment_list_scroll_offset: usize,
pub discussion_comments_loading: bool,
pub discussion_comment_detail_mode: bool,
pub discussion_comment_detail_scroll: usize,
pub help_scroll_offset: usize,
pub help_tab: HelpTab,
pub config_scroll_offset: usize,
pub comment_tab: CommentTab,
pub ai_rally_state: Option<AiRallyState>,
pub working_dir: Option<String>,
data_receiver: PrReceiver<DataLoadResult>,
retry_sender: Option<mpsc::Sender<RefreshRequest>>,
comment_receiver: PrReceiver<Result<Vec<ReviewComment>, String>>,
diff_cache_receiver: Option<mpsc::Receiver<DiffCache>>,
prefetch_receiver: Option<mpsc::Receiver<DiffCache>>,
discussion_comment_receiver: PrReceiver<Result<Vec<DiscussionComment>, String>>,
rally_event_receiver: Option<mpsc::Receiver<RallyEvent>>,
rally_abort_handle: Option<AbortHandle>,
rally_command_sender: Option<mpsc::Sender<OrchestratorCommand>>,
pending_rally_context: Option<AiContext>,
pending_rally_prompt_loader: Option<PromptLoader>,
start_ai_rally_on_load: bool,
pending_ai_rally: bool,
comment_submit_receiver: PrReceiver<CommentSubmitResult>,
mark_viewed_receiver: PrReceiver<MarkViewedResult>,
comment_submitting: bool,
pub submission_result: Option<(bool, String)>,
submission_result_time: Option<Instant>,
pending_approve_body: Option<String>,
pub spinner_frame: usize,
pub selected_inline_comment: usize,
pub jump_stack: Vec<JumpLocation>,
pub pending_keys: SmallVec<[KeyBinding; 4]>,
pub pending_since: Option<Instant>,
pub symbol_popup: Option<SymbolPopupState>,
pub session_cache: SessionCache,
markdown_rich: bool,
pub pr_description_scroll_offset: usize,
pub pr_description_cache: Option<DiffCache>,
pub pr_list_filter: Option<ListFilter>,
pub file_list_filter: Option<ListFilter>,
batch_diff_receiver: Option<mpsc::Receiver<Vec<SingleFileDiffResult>>>,
lazy_diff_receiver: Option<mpsc::Receiver<SingleFileDiffResult>>,
lazy_diff_pending_file: Option<String>,
}
impl App {
pub fn new_loading(
repo: &str,
pr_number: u32,
config: Config,
) -> (Self, mpsc::Sender<DataLoadResult>) {
let (tx, rx) = mpsc::channel(2);
let app = Self {
repo: repo.to_string(),
pr_number: Some(pr_number),
data_state: DataState::Loading,
state: AppState::FileList,
pr_list: None,
selected_pr: 0,
pr_list_scroll_offset: 0,
pr_list_loading: false,
pr_list_has_more: false,
pr_list_state_filter: PrStateFilter::default(),
started_from_pr_list: false,
local_mode: false,
local_auto_focus: false,
local_file_signatures: HashMap::new(),
local_file_patch_signatures: HashMap::new(),
original_pr_number: Some(pr_number),
saved_pr_snapshot: None,
saved_local_snapshot: None,
watcher_handle: None,
refresh_pending: None,
pr_list_receiver: None,
diff_view_return_state: AppState::FileList,
preview_return_state: AppState::DiffView,
previous_state: AppState::FileList,
selected_file: 0,
file_list_scroll_offset: 0,
selected_line: 0,
diff_line_count: 0,
scroll_offset: 0,
multiline_selection: None,
input_mode: None,
input_text_area: TextArea::with_submit_key(config.keybindings.submit.clone()),
config,
should_quit: false,
review_comments: None,
selected_comment: 0,
comment_list_scroll_offset: 0,
comments_loading: false,
file_comment_positions: vec![],
file_comment_lines: HashSet::new(),
comment_panel_open: false,
comment_panel_scroll: 0,
diff_cache: None,
highlighted_cache_store: HashMap::new(),
discussion_comments: None,
selected_discussion_comment: 0,
discussion_comment_list_scroll_offset: 0,
discussion_comments_loading: false,
discussion_comment_detail_mode: false,
discussion_comment_detail_scroll: 0,
help_scroll_offset: 0,
help_tab: HelpTab::default(),
config_scroll_offset: 0,
comment_tab: CommentTab::default(),
ai_rally_state: None,
working_dir: None,
data_receiver: Some((pr_number, rx)),
retry_sender: None,
comment_receiver: None,
diff_cache_receiver: None,
prefetch_receiver: None,
discussion_comment_receiver: None,
rally_event_receiver: None,
rally_abort_handle: None,
rally_command_sender: None,
pending_rally_context: None,
pending_rally_prompt_loader: None,
start_ai_rally_on_load: false,
pending_ai_rally: false,
comment_submit_receiver: None,
mark_viewed_receiver: None,
comment_submitting: false,
submission_result: None,
submission_result_time: None,
pending_approve_body: None,
spinner_frame: 0,
selected_inline_comment: 0,
jump_stack: Vec::new(),
pending_keys: SmallVec::new(),
pending_since: None,
symbol_popup: None,
session_cache: SessionCache::new(),
markdown_rich: false,
pr_description_scroll_offset: 0,
pr_description_cache: None,
pr_list_filter: None,
file_list_filter: None,
batch_diff_receiver: None,
lazy_diff_receiver: None,
lazy_diff_pending_file: None,
};
(app, tx)
}
pub fn new_pr_list(repo: &str, config: Config) -> Self {
Self {
repo: repo.to_string(),
pr_number: None,
data_state: DataState::Loading,
state: AppState::PullRequestList,
pr_list: None,
selected_pr: 0,
pr_list_scroll_offset: 0,
pr_list_loading: true,
pr_list_has_more: false,
pr_list_state_filter: PrStateFilter::default(),
started_from_pr_list: true,
pr_list_receiver: None,
diff_view_return_state: AppState::FileList,
preview_return_state: AppState::DiffView,
previous_state: AppState::PullRequestList,
selected_file: 0,
file_list_scroll_offset: 0,
selected_line: 0,
diff_line_count: 0,
scroll_offset: 0,
multiline_selection: None,
input_mode: None,
input_text_area: TextArea::with_submit_key(config.keybindings.submit.clone()),
config,
should_quit: false,
review_comments: None,
selected_comment: 0,
comment_list_scroll_offset: 0,
comments_loading: false,
file_comment_positions: vec![],
file_comment_lines: HashSet::new(),
comment_panel_open: false,
comment_panel_scroll: 0,
diff_cache: None,
highlighted_cache_store: HashMap::new(),
discussion_comments: None,
selected_discussion_comment: 0,
discussion_comment_list_scroll_offset: 0,
discussion_comments_loading: false,
discussion_comment_detail_mode: false,
discussion_comment_detail_scroll: 0,
help_scroll_offset: 0,
help_tab: HelpTab::default(),
config_scroll_offset: 0,
comment_tab: CommentTab::default(),
ai_rally_state: None,
working_dir: None,
data_receiver: None,
retry_sender: None,
comment_receiver: None,
diff_cache_receiver: None,
prefetch_receiver: None,
discussion_comment_receiver: None,
rally_event_receiver: None,
rally_abort_handle: None,
rally_command_sender: None,
pending_rally_context: None,
pending_rally_prompt_loader: None,
start_ai_rally_on_load: false,
pending_ai_rally: false,
comment_submit_receiver: None,
mark_viewed_receiver: None,
comment_submitting: false,
submission_result: None,
submission_result_time: None,
pending_approve_body: None,
spinner_frame: 0,
selected_inline_comment: 0,
jump_stack: Vec::new(),
pending_keys: SmallVec::new(),
pending_since: None,
symbol_popup: None,
local_mode: false,
local_auto_focus: false,
local_file_signatures: HashMap::new(),
local_file_patch_signatures: HashMap::new(),
original_pr_number: None,
saved_pr_snapshot: None,
saved_local_snapshot: None,
watcher_handle: None,
refresh_pending: None,
session_cache: SessionCache::new(),
markdown_rich: false,
pr_description_scroll_offset: 0,
pr_description_cache: None,
pr_list_filter: None,
file_list_filter: None,
batch_diff_receiver: None,
lazy_diff_receiver: None,
lazy_diff_pending_file: None,
}
}
pub fn set_pr_list_receiver(&mut self, rx: mpsc::Receiver<Result<github::PrListPage, String>>) {
self.pr_list_receiver = Some(rx);
}
pub fn set_data_receiver(&mut self, pr_number: u32, rx: mpsc::Receiver<DataLoadResult>) {
self.data_receiver = Some((pr_number, rx));
}
pub fn set_retry_sender(&mut self, tx: mpsc::Sender<RefreshRequest>) {
self.retry_sender = Some(tx);
}
pub async fn run(&mut self) -> Result<()> {
let mut terminal = ui::setup_terminal()?;
if matches!(self.data_state, DataState::Loaded { .. }) {
self.start_prefetch_all_files();
}
if self.start_ai_rally_on_load && matches!(self.data_state, DataState::Loaded { .. }) {
self.start_ai_rally_on_load = false;
self.start_ai_rally();
}
while !self.should_quit {
self.spinner_frame = self.spinner_frame.wrapping_add(1);
self.poll_pr_list_updates();
self.poll_data_updates();
self.poll_comment_updates();
self.poll_diff_cache_updates();
self.poll_prefetch_updates();
self.poll_batch_diff_updates();
self.poll_lazy_diff_updates();
self.poll_discussion_comment_updates();
self.poll_comment_submit_updates();
self.poll_mark_viewed_updates();
self.poll_rally_events();
terminal.draw(|frame| ui::render(frame, self))?;
self.handle_input(&mut terminal).await?;
}
if let Some(handle) = self.rally_abort_handle.take() {
handle.abort();
}
ui::restore_terminal(&mut terminal)?;
Ok(())
}
pub fn spinner_char(&self) -> &str {
SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
}
pub fn set_working_dir(&mut self, dir: Option<String>) {
self.working_dir = dir;
}
pub fn set_local_mode(&mut self, local: bool) {
self.local_mode = local;
}
pub fn set_local_auto_focus(&mut self, enable: bool) {
self.local_auto_focus = enable;
}
pub fn is_local_mode(&self) -> bool {
self.local_mode
}
pub fn is_local_auto_focus(&self) -> bool {
self.local_auto_focus
}
pub fn is_markdown_rich(&self) -> bool {
self.markdown_rich
}
pub fn set_start_ai_rally_on_load(&mut self, start: bool) {
self.start_ai_rally_on_load = start;
}
pub fn set_pending_ai_rally(&mut self, pending: bool) {
self.pending_ai_rally = pending;
}
pub fn pr_number(&self) -> u32 {
self.pr_number
.expect("pr_number should be set before accessing PR data")
}
pub fn is_submitting_comment(&self) -> bool {
self.comment_submitting
}
pub fn is_pending_approve_confirmation(&self) -> bool {
self.pending_approve_body.is_some()
}
pub fn approve_confirmation_footer_text(&self) -> String {
let kb = &self.config.keybindings;
format!(
"{}: confirm approve | {}/Esc: cancel",
kb.approve.display(),
kb.quit.display(),
)
}
pub fn new_for_test() -> Self {
let config = Config::default();
Self {
repo: "test/repo".to_string(),
pr_number: Some(1),
data_state: DataState::Loading,
state: AppState::FileList,
pr_list: None,
selected_pr: 0,
pr_list_scroll_offset: 0,
pr_list_loading: false,
pr_list_has_more: false,
pr_list_state_filter: PrStateFilter::default(),
started_from_pr_list: false,
pr_list_receiver: None,
diff_view_return_state: AppState::FileList,
preview_return_state: AppState::DiffView,
previous_state: AppState::FileList,
selected_file: 0,
file_list_scroll_offset: 0,
selected_line: 0,
diff_line_count: 0,
scroll_offset: 0,
multiline_selection: None,
input_mode: None,
input_text_area: TextArea::with_submit_key(config.keybindings.submit.clone()),
config,
should_quit: false,
review_comments: None,
selected_comment: 0,
comment_list_scroll_offset: 0,
comments_loading: false,
file_comment_positions: vec![],
file_comment_lines: HashSet::new(),
comment_panel_open: false,
comment_panel_scroll: 0,
diff_cache: None,
highlighted_cache_store: HashMap::new(),
discussion_comments: None,
selected_discussion_comment: 0,
discussion_comment_list_scroll_offset: 0,
discussion_comments_loading: false,
discussion_comment_detail_mode: false,
discussion_comment_detail_scroll: 0,
help_scroll_offset: 0,
help_tab: HelpTab::default(),
config_scroll_offset: 0,
comment_tab: CommentTab::default(),
ai_rally_state: None,
working_dir: None,
data_receiver: None,
retry_sender: None,
comment_receiver: None,
diff_cache_receiver: None,
prefetch_receiver: None,
discussion_comment_receiver: None,
rally_event_receiver: None,
rally_abort_handle: None,
rally_command_sender: None,
pending_rally_context: None,
pending_rally_prompt_loader: None,
start_ai_rally_on_load: false,
pending_ai_rally: false,
comment_submit_receiver: None,
mark_viewed_receiver: None,
comment_submitting: false,
submission_result: None,
submission_result_time: None,
pending_approve_body: None,
spinner_frame: 0,
selected_inline_comment: 0,
jump_stack: Vec::new(),
pending_keys: SmallVec::new(),
pending_since: None,
symbol_popup: None,
session_cache: SessionCache::new(),
local_mode: false,
local_auto_focus: false,
local_file_signatures: HashMap::new(),
local_file_patch_signatures: HashMap::new(),
original_pr_number: None,
saved_pr_snapshot: None,
saved_local_snapshot: None,
watcher_handle: None,
refresh_pending: None,
markdown_rich: false,
pr_description_scroll_offset: 0,
pr_description_cache: None,
pr_list_filter: None,
file_list_filter: None,
batch_diff_receiver: None,
lazy_diff_receiver: None,
lazy_diff_pending_file: None,
}
}
#[cfg(test)]
pub fn set_submitting_for_test(&mut self, submitting: bool) {
self.comment_submitting = submitting;
}
#[cfg(test)]
pub fn set_pending_approve_body_for_test(&mut self, body: Option<String>) {
self.pending_approve_body = body;
}
}