use crate::action::Action;
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::{self, Provider};
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 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), crate::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: u64,
view_gen: u64,
pending_context_sha: Option<String>,
context_debounce_gen: std::sync::Arc<std::sync::atomic::AtomicU64>,
status: Option<String>,
offline: bool,
pub should_quit: bool,
pub force_redraw: bool,
pending_editor: Option<crate::editor::EditorJob>,
pending_clipboard: Option<String>,
}
pub(crate) fn provider_status(error: &crate::provider::ProviderError) -> String {
use crate::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 {
fn open_history_fetch(&mut self) {
let target = self
.browser
.repo_coords()
.zip(self.browser.history_path().map(str::to_string));
if let Some(((owner, name), path)) = target {
let ref_ = self.browser.current_ref().map(str::to_string);
self.spawn_log(format!("{owner}/{name}"), path, ref_);
}
}
pub fn new(tx: AppTx, config: Config, theme: Theme) -> Self {
let (provider, outcome) = provider::build(&config);
let mut app = Self::build(State::load(), tx, provider, false, config, theme);
match outcome {
provider::BuildOutcome::Ready => {}
provider::BuildOutcome::Warn(warning) => app.status = Some(warning),
provider::BuildOutcome::Missing(decl) => {
app.consent = Some(ConsentPopup::new(decl));
}
}
if !app.offline && app.config.update.check && crate::update::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,
},
theme,
config,
state,
tx,
provider,
highlighter: Highlighter::new(&theme),
highlight_syntax: theme.syntax,
search_gen: 0,
view_gen: 0,
pending_context_sha: None,
context_debounce_gen: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
status: None,
offline,
should_quit: false,
force_redraw: false,
pending_editor: None,
pending_clipboard: None,
}
}
fn effective_mode(&self) -> Mode {
if self.command_line.is_some() {
return Mode::Insert;
}
if let Some(settings) = &self.settings {
return settings.effective_mode();
}
if self.help.is_some() || self.wizard.is_some() {
return Mode::Browse;
}
if let Some(view) = &self.search_view {
if self.mode == Mode::Leader {
return Mode::Leader;
}
return view.effective_mode();
}
match &self.popup {
Some(popup) => popup.effective_mode(),
None => self.mode,
}
}
pub fn handle_key(&mut self, key: KeyEvent) {
let action = self.dispatch(key);
self.handle_action(action);
}
fn dispatch(&mut self, key: KeyEvent) -> Action {
if let Some(consent) = &mut self.consent {
return consent.handle_key(key);
}
if let Some(wizard) = &mut self.wizard {
return wizard.handle_key(key);
}
if let Some(settings) = &mut self.settings {
return settings.handle_key(key);
}
if let Some(help) = &mut self.help {
return help.handle_key(key);
}
if let Some(refs) = &mut self.refs_popup {
return refs.handle_key(key);
}
if let Some(command_line) = &mut self.command_line {
return command_line.handle_key(key);
}
if let Some(view) = &mut self.search_view {
if self.mode == Mode::Leader {
return keymap::leader(key.code);
}
return view.handle_key(key);
}
if let Some(popup) = &mut self.popup {
return popup.handle_key(key);
}
match self.mode {
Mode::Browse => keymap::browsing(key.code),
Mode::Visual => keymap::visual(key.code),
Mode::Search => match self.browser.filter_input.handle_key(key) {
Outcome::Changed => Action::Noop, Outcome::Submitted => Action::CommitFilter,
Outcome::Cancelled => Action::ClearFilter,
Outcome::Noop => Action::Noop,
},
Mode::Find => match self.browser.find_input.handle_key(key) {
Outcome::Changed => Action::UpdateFind,
Outcome::Submitted => Action::CommitFind,
Outcome::Cancelled => Action::CancelFind,
Outcome::Noop => Action::Noop,
},
Mode::Leader => keymap::leader(key.code),
Mode::History => {
if self.browser.history_filtering() {
self.browser.history_filter_key(key);
Action::Noop
} else {
keymap::history(key.code)
}
}
Mode::Preview => self.browser.preview_key(key),
_ => Action::Noop,
}
}
pub fn handle_action(&mut self, action: Action) {
let left = self
.try_browse(action)
.and_then(|a| self.try_search(a))
.and_then(|a| self.try_lenses(a))
.and_then(|a| self.try_lifecycle(a));
debug_assert!(left.is_none(), "unrouted action: {left:?}");
let _ = left;
if self.mode == Mode::Search {
self.browser.apply_filter();
}
self.sync_highlight_theme();
self.maybe_load_blob();
if let Some(note) = self.provider.take_notice() {
self.status = Some(note);
}
}
fn effective_theme(&self) -> Theme {
self.settings
.as_ref()
.and_then(SettingsPopup::preview_theme)
.unwrap_or(self.theme)
}
fn sync_highlight_theme(&mut self) {
let theme = self.effective_theme();
if theme.syntax == self.highlight_syntax {
return;
}
self.highlighter.set_theme(&theme);
self.browser.restyle_blobs(&self.highlighter);
self.highlight_syntax = theme.syntax;
}
fn maybe_load_blob(&mut self) {
if let Some((sha, name)) = self.browser.take_blob_request() {
self.handle_action(Action::LoadBlob { sha, name });
}
}
pub fn take_editor_job(&mut self) -> Option<crate::editor::EditorJob> {
self.pending_editor.take()
}
pub fn take_clipboard(&mut self) -> Option<String> {
self.pending_clipboard.take()
}
fn band_apply_or_fetch(&mut self) {
if !self.provider.capabilities().log || self.browser.at_commit_view() {
return;
}
let Some((owner, name)) = self.browser.repo_coords() else {
return;
};
let Some((path, _)) = self.browser.selected_file() else {
return;
};
let ref_ = self
.browser
.current_ref()
.map(str::to_string)
.unwrap_or_default();
let key = (format!("{owner}/{name}"), path.clone(), ref_.clone());
match self.last_commits.get(&key) {
Some(entry) => {
self.browser.preview.set_band(
Some(path),
Some(crate::components::preview::BandContext {
sha: entry.sha.clone(),
subject: entry.subject.clone(),
author: entry.author.clone(),
date: entry.date.clone(),
}),
);
}
None => {
self.spawn_last_commit(format!("{owner}/{name}"), path, Some(ref_));
}
}
}
pub fn update_exit_note(&self) -> Option<String> {
self.update_tag.as_ref()?;
crate::update::disk_newer_note()
}
pub fn cursor_style(&self) -> Option<ratatui::crossterm::cursor::SetCursorStyle> {
if let Some(cl) = &self.command_line {
return cl.cursor_style();
}
if let Some(settings) = &self.settings {
return settings.cursor_style();
}
if let Some(view) = &self.search_view {
return view.cursor_style();
}
self.popup.as_ref().and_then(|p| p.cursor_style())
}
pub fn render(&mut self, frame: &mut Frame, area: Rect) {
let theme = self.effective_theme();
let mode = self.effective_mode();
let overlay_up = self.popup.is_some()
|| self.wizard.is_some()
|| self.settings.is_some()
|| self.help.is_some()
|| self.command_line.is_some()
|| self.refs_popup.is_some()
|| self.search_view.is_some();
let strip = mode == Mode::Leader || (!overlay_up && mode != Mode::Browse);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints(if strip {
vec![
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(1),
]
} else {
vec![Constraint::Min(1), Constraint::Length(1)]
})
.split(area);
if let Some(view) = &mut self.search_view {
view.render(frame, rows[0], &theme);
self.modeline.context = view.context();
} else {
let zoomed = matches!(self.mode, Mode::Preview | Mode::History)
|| (self.mode == Mode::Find && self.find_return == Some(Mode::Preview));
self.browser.preview.focused = zoomed;
self.browser.render(frame, rows[0], &theme, zoomed);
self.modeline.context = self.browser.context();
}
if strip {
frame.render_widget(
Paragraph::new(crate::components::modeline::hint_strip_line(
mode,
rows[1].width as usize,
&theme,
)),
rows[1],
);
}
self.modeline.status = self.status.clone().or_else(|| self.degraded.clone());
let modeline_row = rows[rows.len() - 1];
self.modeline.update_tag = self.update_tag.clone();
self.modeline.render(frame, modeline_row, mode, &theme);
if let Some(popup) = &mut self.popup {
popup.render(frame, rows[0], &theme);
}
if let Some(help) = &mut self.help {
help.render(frame, rows[0], &theme);
}
if let Some(settings) = &mut self.settings {
settings.render(frame, rows[0], &theme);
}
if let Some(wizard) = &mut self.wizard {
wizard.render(frame, rows[0], &theme);
}
if let Some(refs) = &mut self.refs_popup {
refs.render(frame, rows[0], &theme);
}
if let Some(consent) = &mut self.consent {
consent.render(frame, rows[0], &theme);
}
if let Some(command_line) = &mut self.command_line {
command_line.render(frame, rows[0], &theme);
}
}
}
pub fn trace(msg: &str) {
if let Ok(path) = std::env::var("ROOTLE_TRACE") {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
let _ = writeln!(f, "{:?} {msg}", std::time::SystemTime::now());
}
}
}
#[cfg(test)]
mod tests {
use crate::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");
}
}