use anyhow::Result;
use smallvec::SmallVec;
use std::collections::HashMap;
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::diff_store::{DiffCacheStore, DiffScrollState, ScrollMode, MAX_STORE_ENTRIES};
use crate::filter::ListFilter;
use crate::github;
use crate::keybinding::KeyBinding;
use crate::loader::{DataLoadResult, SingleFileDiffResult};
use crate::ui;
use crate::ui::text_area::TextArea;
use std::time::Instant;
mod types;
pub use types::{
hash_string, AiRallyState, AppState, CachedDiffLine, CachedShellLine, ChecksState,
CockpitMenuItem, CockpitState, CommentPosition, CommentState, CommentTab, CommitLogState,
DataState, DestructiveOp, DiffCache, FileStatus, GitOpsState, GitStatusEntry, HelpTab,
IndexEntry, InputMode, InternedSpan, IssueDetailFocus, IssueState, JumpLocation, LeftPaneFocus,
LineInputContext, LoadState, LogEntry, LogEventType, MultilineSelection, PauseState,
PendingGitOpsConfirm, PermissionInfo, PrListState, RefreshRequest, RepoSymbolSearchResult,
ReviewAction, ShellCommandResult, ShellPhase, ShellState, SimulationPreview, SimulationResult,
SpanVec, SymbolPopupState, SymbolSearchState, SymbolSearchUpdate, TreeRow, UndoAction,
WatcherHandle,
};
use types::MarkViewedResult;
mod ai_rally;
mod cockpit;
mod comments;
mod diff_cache;
pub mod file_tree;
mod filter;
mod git_ops;
mod input;
mod input_diff;
mod input_text;
mod issue_detail;
mod issue_list;
mod key_sequence;
mod local_mode;
mod polling;
mod pr_list;
mod shell_command;
mod symbol;
#[cfg(test)]
mod tests;
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub(crate) type PrReceiver<T> = Option<(u32, mpsc::Receiver<T>)>;
pub struct SuggestionHighlightCache {
pub content_hash: u64,
pub filename: String,
pub theme_name: String,
pub lines: Vec<ratatui::text::Line<'static>>,
}
pub struct App {
pub repo: String,
pub pr_number: Option<u32>,
pub data_state: DataState,
pub state: AppState,
pub prs: PrListState,
pub started_from_pr_list: bool,
local_mode: bool,
local_auto_focus: bool,
pub(crate) zen_mode: bool,
local_file_signatures: HashMap<String, u64>,
local_file_patch_signatures: HashMap<String, u64>,
original_pr_number: Option<u32>,
watcher_handle: Option<WatcherHandle>,
refresh_pending: Option<Arc<AtomicBool>>,
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 diff_scroll: DiffScrollState,
pub multiline_selection: Option<MultilineSelection>,
pub input_mode: Option<InputMode>,
pub input_text_area: TextArea,
pub config: Config,
pub should_quit: bool,
pub cmt: CommentState,
pub diff_store: DiffCacheStore<usize>,
pub help_scroll_offset: usize,
pub help_tab: HelpTab,
pub config_scroll_offset: usize,
pub ai_rally_state: Option<AiRallyState>,
pub working_dir: Option<String>,
data_receiver: PrReceiver<DataLoadResult>,
retry_sender: Option<mpsc::Sender<RefreshRequest>>,
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>,
pending_rally_seed_review: Option<crate::ai::ReviewerOutput>,
start_ai_rally_on_load: bool,
pending_ai_rally: bool,
mark_viewed_receiver: PrReceiver<MarkViewedResult>,
pub spinner_frame: usize,
pub jump_stack: Vec<JumpLocation>,
pub pending_keys: SmallVec<[KeyBinding; 4]>,
pub pending_since: Option<Instant>,
pub symbol_popup: Option<SymbolPopupState>,
pub symbol_search: SymbolSearchState,
pub session_cache: SessionCache,
markdown_rich: bool,
pub suggestion_highlight_cache: Option<SuggestionHighlightCache>,
pub pr_description_scroll_offset: usize,
pub pr_description_cache: Option<DiffCache>,
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>,
pub chk: ChecksState,
pub git_ops_state: Option<GitOpsState>,
pub issue_state: Option<IssueState>,
pub issue_detail_return: bool,
pub update_available: Option<String>,
update_check_receiver: Option<mpsc::Receiver<Option<String>>>,
pub tree_mode_active: bool,
pub file_tree_state: Option<file_tree::FileTreeState>,
pub shell_state: Option<ShellState>,
shell_result_receiver: Option<mpsc::Receiver<ShellCommandResult>>,
shell_abort_handle: Option<AbortHandle>,
pub cockpit_state: Option<CockpitState>,
pub home_state: Option<AppState>,
}
impl App {
fn base_app(repo: String, config: Config) -> Self {
let submit_key = config.keybindings.submit.clone();
Self {
repo,
pr_number: Some(1),
data_state: DataState::Loading,
state: AppState::FileList,
prs: PrListState::default(),
started_from_pr_list: false,
local_mode: false,
local_auto_focus: false,
zen_mode: false,
local_file_signatures: HashMap::new(),
local_file_patch_signatures: HashMap::new(),
original_pr_number: None,
watcher_handle: None,
refresh_pending: None,
diff_view_return_state: AppState::FileList,
preview_return_state: AppState::DiffView,
previous_state: AppState::FileList,
selected_file: 0,
file_list_scroll_offset: 0,
diff_scroll: DiffScrollState::new(ScrollMode::Margin),
multiline_selection: None,
input_mode: None,
input_text_area: TextArea::with_submit_key(submit_key),
config,
should_quit: false,
cmt: CommentState::default(),
diff_store: DiffCacheStore::new(MAX_STORE_ENTRIES),
help_scroll_offset: 0,
help_tab: HelpTab::default(),
config_scroll_offset: 0,
ai_rally_state: None,
working_dir: None,
data_receiver: None,
retry_sender: None,
rally_event_receiver: None,
rally_abort_handle: None,
rally_command_sender: None,
pending_rally_context: None,
pending_rally_prompt_loader: None,
pending_rally_seed_review: None,
start_ai_rally_on_load: false,
pending_ai_rally: false,
mark_viewed_receiver: None,
spinner_frame: 0,
jump_stack: Vec::new(),
pending_keys: SmallVec::new(),
pending_since: None,
symbol_popup: None,
symbol_search: SymbolSearchState::Idle,
session_cache: SessionCache::new(),
markdown_rich: false,
suggestion_highlight_cache: None,
pr_description_scroll_offset: 0,
pr_description_cache: None,
file_list_filter: None,
batch_diff_receiver: None,
lazy_diff_receiver: None,
lazy_diff_pending_file: None,
chk: ChecksState::default(),
git_ops_state: None,
issue_state: None,
issue_detail_return: false,
update_available: None,
update_check_receiver: None,
tree_mode_active: false,
file_tree_state: None,
shell_state: None,
shell_result_receiver: None,
shell_abort_handle: None,
cockpit_state: None,
home_state: None,
}
}
pub fn new_loading(
repo: &str,
pr_number: u32,
config: Config,
) -> (Self, mpsc::Sender<DataLoadResult>) {
let (tx, rx) = mpsc::channel(2);
let mut app = Self::base_app(repo.to_string(), config);
app.pr_number = Some(pr_number);
app.original_pr_number = Some(pr_number);
app.data_receiver = Some((pr_number, rx));
app.zen_mode = app.config.layout.zen_mode;
(app, tx)
}
pub fn new_pr_list(repo: &str, config: Config) -> Self {
let zen_mode = config.layout.zen_mode;
let mut app = Self::base_app(repo.to_string(), config);
app.pr_number = None;
app.state = AppState::PullRequestList;
app.prs.pr_list = LoadState::Loading;
app.started_from_pr_list = true;
app.previous_state = AppState::PullRequestList;
app.zen_mode = zen_mode;
app
}
pub fn new_cockpit(repo: &str, config: Config, repo_available: bool) -> Self {
let zen_mode = config.layout.zen_mode;
let mut app = Self::base_app(repo.to_string(), config);
app.pr_number = None;
app.state = AppState::Cockpit;
app.home_state = Some(AppState::Cockpit);
app.zen_mode = zen_mode;
app.cockpit_state = Some(CockpitState::new(repo_available));
app
}
pub fn set_pr_list_receiver(&mut self, rx: mpsc::Receiver<Result<github::PrListPage, String>>) {
self.prs.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 fn set_update_check_receiver(&mut self, rx: mpsc::Receiver<Option<String>>) {
self.update_check_receiver = Some(rx);
}
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();
self.poll_checks_updates();
self.poll_ci_status_updates();
self.poll_git_ops_updates();
self.poll_issue_list_updates();
self.poll_issue_detail_updates();
self.poll_linked_prs_updates();
self.poll_cockpit_updates();
self.poll_issue_comment_submit_updates();
self.poll_update_check();
self.poll_symbol_search_updates();
self.poll_shell_result();
if let SymbolSearchState::Ready(..) = &self.symbol_search {
if let Some(result) = self.symbol_search.take_ready() {
let full_path = std::path::Path::new(&result.repo_root).join(&result.file_path);
let path_str = full_path.to_string_lossy().to_string();
let line = result.line_number;
let editor = self.config.editor.clone();
ui::restore_terminal(&mut terminal)?;
let _ = crate::editor::open_file_at_line(editor.as_deref(), &path_str, line);
terminal = ui::setup_terminal()?;
}
}
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(crate) fn toggle_zen_mode(&mut self) {
self.zen_mode = !self.zen_mode;
let msg = if self.zen_mode {
"Zen mode: ON"
} else {
"Zen mode: OFF"
};
self.cmt.submission_result = Some((true, msg.to_string()));
self.cmt.submission_result_time = Some(Instant::now());
}
pub(crate) fn enter_diff_from_file_list(&mut self) {
if self.state == AppState::SplitViewFileList {
self.state = AppState::SplitViewDiff;
} else if self.zen_mode {
self.diff_view_return_state = AppState::FileList;
self.state = AppState::DiffView;
} else {
self.state = AppState::SplitViewDiff;
}
}
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.cmt.comment_submitting
}
pub fn is_pending_approve_confirmation(&self) -> bool {
self.cmt.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 {
Self::base_app("test/repo".to_string(), Config::default())
}
pub fn is_file_tree_active(&self) -> bool {
self.tree_mode_active && self.file_tree_state.is_some() && self.file_list_filter.is_none()
}
#[cfg(test)]
pub fn set_submitting_for_test(&mut self, submitting: bool) {
self.cmt.comment_submitting = submitting;
}
#[cfg(test)]
pub fn set_pending_approve_body_for_test(&mut self, body: Option<String>) {
self.cmt.pending_approve_body = body;
}
}