mod diagnostics;
mod effects;
mod input;
mod presentation;
use crate::action::Action;
use crate::components::Component;
use crate::components::browser::Browser;
use crate::components::clone_wizard::CloneWizard;
use crate::components::command_line::CommandLine;
use crate::components::consent_popup::ConsentPopup;
use crate::components::global_search::GlobalSearch;
use crate::components::keybinds_popup::KeybindsPopup;
use crate::components::modeline::Modeline;
use crate::components::refs_popup::RefsPopup;
use crate::components::search_popup::SearchPopup;
use crate::components::settings_popup::SettingsPopup;
use crate::components::vim_input::Outcome;
use crate::config::Config;
use crate::event::AppTx;
use crate::highlight::Highlighter;
use crate::keymap;
use crate::mode::Mode;
use crate::provider;
use crate::request::{CommitGeneration, SearchGeneration, ViewGeneration};
use crate::state::State;
use crate::theme::Theme;
use ratatui::Frame;
use ratatui::crossterm::event::KeyEvent;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::widgets::Paragraph;
use rootle_provider::Provider;
use std::sync::Arc;
mod actions;
mod events;
mod workers;
pub struct App {
mode: Mode,
browser: Browser,
popup: Option<SearchPopup>,
search_view: Option<GlobalSearch>,
help: Option<KeybindsPopup>,
command_line: Option<CommandLine>,
settings: Option<SettingsPopup>,
wizard: Option<CloneWizard>,
refs_popup: Option<RefsPopup>,
refs_baseline: Option<String>,
consent: Option<ConsentPopup>,
degraded: Option<String>,
last_commits: std::collections::HashMap<(String, String, String), rootle_provider::LogEntry>,
history_return: Option<Mode>,
find_return: Option<Mode>,
update_tag: Option<String>,
modeline: Modeline,
theme: Theme,
config: Config,
state: State,
tx: AppTx,
provider: Arc<dyn Provider>,
highlighter: Highlighter,
highlight_syntax: crate::theme::Syntax,
search_gen: SearchGeneration,
view_gen: ViewGeneration,
commit_generation: CommitGeneration,
pending_context_sha: Option<String>,
context_debounce_gen: std::sync::Arc<std::sync::atomic::AtomicU64>,
outstanding: workers::Outstanding,
status: Option<String>,
offline: bool,
pub should_quit: bool,
pub force_redraw: bool,
pending_editor: Option<crate::editor::EditorJob>,
pending_clipboard: Option<String>,
trace_failure: Option<String>,
}
pub(crate) fn provider_status(error: &rootle_provider::ProviderError) -> String {
use rootle_provider::ErrorKind;
match error.kind {
ErrorKind::Auth => format!(
"auth failed: {} — refresh provider credentials",
error.message
),
ErrorKind::RateLimited => match error.retry_after {
Some(d) => format!("provider throttled — retry in {}s", d.as_secs()),
None => format!("provider throttled: {}", error.message),
},
_ => error.message.clone(),
}
}
fn forge_name(config: &Config, provider: &dyn Provider) -> String {
config
.provider
.name
.clone()
.unwrap_or_else(|| provider.name().trim_start_matches("stdio:").to_owned())
}
impl App {
pub fn new(tx: AppTx, config: Config, theme: Theme) -> Self {
let op = rootle_trace::operation_id();
let (provider, outcome) = rootle_trace::in_operation(op, || {
rootle_trace::record_with(
rootle_trace::EventKind::ProviderLifecycle,
|| serde_json::json!({"stage": "compose"}),
);
let built = provider::build(&config);
rootle_trace::record_with(rootle_trace::EventKind::ProviderLifecycle, || {
serde_json::json!({
"stage": "compose",
"outcome": match &built.1 {
provider::BuildOutcome::Ready => "ready",
provider::BuildOutcome::Warn(_) => "warn",
provider::BuildOutcome::Health(_) => "health",
provider::BuildOutcome::Missing(_) => "missing",
},
})
});
built
});
let mut app = Self::build(State::load(), tx, provider, false, config, theme);
match outcome {
provider::BuildOutcome::Ready => {}
provider::BuildOutcome::Warn(warning) => {
app.degraded = Some(warning.clone());
app.status = Some(warning);
}
provider::BuildOutcome::Health(issue) => {
app.consent = Some(ConsentPopup::health(issue));
}
provider::BuildOutcome::Missing(decl) => {
app.consent = Some(ConsentPopup::new(decl));
}
}
if !app.offline && app.config.update.check && crate::selfupdate::check_allowed() {
app.spawn_update_check();
}
if let Some(org) = app.browser.selected_org() {
app.handle_action(Action::LoadOrgRepos(org));
}
app
}
pub fn with(state: State, tx: AppTx) -> Self {
Self::build(
state,
tx,
provider::offline(),
true,
Config::default(),
Theme::catppuccin_mocha(),
)
}
fn build(
state: State,
tx: AppTx,
provider: Arc<dyn Provider>,
offline: bool,
config: Config,
theme: Theme,
) -> Self {
let forge = forge_name(&config, provider.as_ref());
let icon = config.provider.icon.clone().or_else(|| provider.icon());
let popup = if state.recent_repos.is_empty()
&& state.recent_orgs.is_empty()
&& state.last_repo.is_none()
{
let mut p = SearchPopup::with_prefill(state.last_repo.as_deref());
p.forge = forge.clone();
Some(p)
} else {
None
};
App {
mode: Mode::Browse,
browser: Browser::new(&state.recent_orgs, &provider.default_orgs()),
popup, search_view: None,
help: None,
command_line: None,
settings: None,
wizard: None,
refs_popup: None,
refs_baseline: None,
consent: None,
degraded: None,
last_commits: std::collections::HashMap::new(),
history_return: None,
find_return: None,
update_tag: None,
modeline: Modeline {
forge,
icon,
context: String::new(),
status: None,
update_tag: None,
degraded: false,
},
theme,
config,
state,
tx,
provider,
highlighter: Highlighter::new(&theme),
highlight_syntax: theme.syntax,
search_gen: SearchGeneration::default(),
view_gen: ViewGeneration::default(),
commit_generation: CommitGeneration::default(),
pending_context_sha: None,
context_debounce_gen: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
outstanding: workers::Outstanding::default(),
status: None,
offline,
should_quit: false,
force_redraw: false,
pending_editor: None,
pending_clipboard: None,
trace_failure: None,
}
}
pub(crate) fn outstanding_workers(&self) -> workers::Outstanding {
self.outstanding.clone()
}
pub fn set_degraded_for_test(&mut self, note: String) {
self.degraded = Some(note.clone());
self.status = Some(note);
}
pub fn clear_status_for_test(&mut self) {
self.status = None;
}
pub fn report_trace_failure(&mut self, message: String) {
self.trace_failure.get_or_insert(message);
}
pub fn record_trace_state(&self, reason: &'static str) {
diagnostics::record_state(self, reason);
}
}
#[cfg(test)]
mod tests {
use rootle_provider::{ErrorKind, ProviderError};
use std::time::Duration;
#[test]
fn provider_status_renders_per_kind() {
let auth = ProviderError::new(ErrorKind::Auth, "bad credentials");
let rendered = super::provider_status(&auth);
assert!(rendered.contains("bad credentials"));
assert!(rendered.contains("refresh provider credentials"));
let throttled = ProviderError::new(ErrorKind::RateLimited, "slow down")
.with_retry_after(Duration::from_secs(37));
assert_eq!(
super::provider_status(&throttled),
"provider throttled — retry in 37s"
);
let plain = ProviderError::other("something broke");
assert_eq!(super::provider_status(&plain), "something broke");
}
}