mod edit;
mod hints;
mod keys;
mod lenses;
mod navigate;
mod open;
mod panel;
mod progress;
mod refresh;
mod render;
mod section_manager;
mod stats;
mod view;
mod zip;
use super::app::progress::REFRESH_LABEL;
use crate::tui::terminal::{Tui, TuiEvent};
use std::collections::{HashMap, HashSet};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::{Duration, Instant};
use chrono::{DateTime, Local};
use ratatui::Frame;
use ratada::input::InputField;
use ratada::quit::{self, QuitConfirm, QuitKind};
use ratada::shortcut_hints;
use ratada::spinner::Spinner;
use crate::config::Config;
use crate::domain::filter::{Tab, TabKind};
use crate::domain::sort::{SortDir, SortMode};
use crate::keymap::Keymap;
use crate::service::preview_service::PreviewLog;
use crate::service::repo_service::RepoService;
use crate::service::stats_service::{
self, CodeUpdate, GitStatsUpdate, StatsCache,
};
use crate::service::status_service::{self, StatusUpdate};
use crate::service::ui_state_service::{self, TabView};
use crate::service::zip_service::ZipUpdate;
use crate::storage::git_client::GitClient;
use crate::theme::Skin;
use crate::tui::columns::ColumnSet;
use crate::tui::form::RepoForm;
use crate::tui::path_picker::PathPicker;
use crate::tui::presentation::IconSet;
use crate::tui::preview::PreviewLayout;
use crate::tui::section_picker::SectionPicker;
use crate::tui::sections_modal::SectionsModal;
use crate::tui::skin::Colors;
use crate::tui::widgets::{ConfirmModal, SelectModal, TextPrompt};
const STATUS_TTL: Duration = Duration::from_secs(4);
const SPINNER_INTERVAL: Duration = Duration::from_millis(120);
pub enum RunOutcome {
Quit,
Jumped,
LaunchGitTool(PathBuf),
LaunchGitToolInline(PathBuf),
OpenFile(PathBuf),
OpenWith(PathBuf),
}
enum Overlay {
None,
Help,
Confirm(ConfirmModal, Vec<usize>),
Prompt(TextPrompt, usize),
Form(Box<RepoForm>, EditTarget),
Picker(PathPicker, PickerIntent),
SectionPicker(Box<SectionPicker>, Box<RepoForm>, EditTarget),
Errors(SelectModal, Vec<usize>),
ErrorAction(SelectModal, usize),
SectionJump(SelectModal, Vec<usize>),
Sections(SectionsModal),
SectionPrompt(TextPrompt, SectionPromptKind),
SectionDelete(ConfirmModal, String),
Sort(SelectModal, Vec<SortMode>),
}
enum SectionPromptKind {
New,
Rename(String),
}
enum PickerIntent {
Repair(usize),
FormPath(Box<RepoForm>, EditTarget),
}
#[derive(Clone)]
enum EditTarget {
Add,
One(usize),
Bulk(Vec<usize>),
}
pub struct App {
config: Config,
service: RepoService,
icons: IconSet,
skin: Skin,
colors: Colors,
keymap: Keymap,
git_client: Arc<dyn GitClient>,
cache_path: PathBuf,
zip_cache_path: PathBuf,
ui_state_path: PathBuf,
tab: Tab,
cursor: usize,
tab_state: [TabState; 2],
filtering: bool,
filter: InputField,
overlay: Overlay,
help_scroll: crate::tui::scroll::Scroll,
help_query: InputField,
status_msg: Option<(String, Instant)>,
loading: Option<(usize, usize)>,
loading_label: &'static str,
loading_detail: Option<String>,
loading_name_width: usize,
zip_rx: Option<Receiver<ZipUpdate>>,
zip_backups: HashMap<PathBuf, DateTime<Local>>,
cache_generated_at: Option<DateTime<Local>>,
last_fetched: Option<DateTime<Local>>,
status_jobs: Vec<RefreshJob>,
refreshing: HashSet<PathBuf>,
spinner: Spinner,
spinner_at: Instant,
selected: HashSet<usize>,
anchor: Option<usize>,
list_offset: std::cell::Cell<usize>,
table_offset: std::cell::Cell<usize>,
auto_refresh: bool,
refreshed_tabs: HashSet<Tab>,
files_missing: HashSet<PathBuf>,
show_slugs: bool,
changes_only: bool,
tab_focus: HashMap<Tab, PathBuf>,
list_height: std::cell::Cell<usize>,
preview: PreviewLayout,
preview_scroll: crate::tui::scroll::Scroll,
stats_path: PathBuf,
stats: StatsCache,
code_rx: Option<Receiver<CodeUpdate>>,
git_stats_rx: Option<Receiver<GitStatsUpdate>>,
computing: HashSet<PathBuf>,
preview_log: HashMap<PathBuf, Vec<String>>,
preview_tx: Sender<PreviewLog>,
preview_rx: Receiver<PreviewLog>,
preview_pending: HashSet<PathBuf>,
preview_target: Option<PathBuf>,
preview_target_at: Instant,
}
#[derive(Debug, Clone, Copy)]
struct TabState {
sort: SortMode,
sort_dir: SortDir,
columns: ColumnSet,
grouped: bool,
fav_float: bool,
}
fn tab_state_from(view: &TabView, kind: TabKind) -> TabState {
TabState {
sort: view.sort,
sort_dir: view.sort_dir,
columns: ColumnSet::from_key(&view.columns)
.available_on(kind.active_tab()),
grouped: view.grouped,
fav_float: view.fav_float,
}
}
struct RefreshJob {
rx: Receiver<StatusUpdate>,
fetched: bool,
bar: bool,
remaining: HashSet<PathBuf>,
}
pub enum StartupStatus {
Cached,
Refresh {
fetch: bool,
},
}
pub fn run(mut app: App, tui: &mut Tui) -> io::Result<RunOutcome> {
loop {
app.drain_status();
app.drain_zip();
app.drain_preview();
app.drain_code_stats();
app.drain_git_stats();
app.request_preview_log();
tui.draw(|frame| app.render(frame))?;
let timeout =
if app.is_refreshing() || app.is_zipping() || app.is_computing() {
80
} else if app.preview_busy() {
60
} else {
150
};
let outcome = match tui.poll_event(Duration::from_millis(timeout))? {
Some(TuiEvent::Quit) => Some(RunOutcome::Quit),
Some(TuiEvent::Key(key)) => match app.handle_key(key) {
Some(RunOutcome::Quit) => {
let repaint = |frame: &mut Frame| app.render(frame);
quit::request(tui, QuitKind::Soft, &repaint)
.then_some(RunOutcome::Quit)
}
other => other,
},
Some(TuiEvent::Paste(text)) => {
app.handle_paste(&text);
None
}
Some(TuiEvent::Resize) | None => None,
};
if let Some(outcome) = outcome {
match outcome {
RunOutcome::LaunchGitToolInline(path) => {
app.run_git_inline(tui, &path)?;
}
other => return Ok(other),
}
}
app.tick();
}
}
fn install_quit_confirmation(confirm_quit: bool, skin: Skin) {
quit::set_confirm(if confirm_quit {
QuitConfirm::Soft
} else {
QuitConfirm::Never
});
quit::set_guard(move |tui, _kind, bg| {
ratada::modal::confirm(tui, &skin, " Quit hop? ", bg)
});
}
impl App {
pub fn new(
config: Config,
service: RepoService,
git_client: Arc<dyn GitClient>,
cache_path: PathBuf,
ui_state_path: PathBuf,
startup: StartupStatus,
) -> Self {
let icons = IconSet::new(config.appearance.glyphs);
let skin = config.skin();
let colors = Colors::from_palette(&skin.palette);
let keymap = config.keymap();
let cached = status_service::load_cache(&cache_path);
let ui = ui_state_service::load(&ui_state_path);
shortcut_hints::set_visible(ui.hints_visible);
install_quit_confirmation(config.confirm_quit, skin);
let state_dir = cache_path.parent().unwrap_or_else(|| Path::new("."));
let zip_cache_path = state_dir.join("zip-manifests.toml");
let stats_path = state_dir.join("stats-cache.toml");
let stats = stats_service::load_cache(&stats_path);
let mut service = service;
service.apply_git_infos(&cached.infos);
let (preview_tx, preview_rx) = mpsc::channel();
let tab_state = [
tab_state_from(&ui.git, TabKind::Git),
tab_state_from(&ui.files, TabKind::Files),
];
let mut app = App {
config,
service,
icons,
skin,
colors,
keymap,
git_client,
cache_path,
zip_cache_path,
ui_state_path,
tab: ui.tab,
cursor: 0,
tab_state,
filtering: false,
filter: InputField::new(""),
overlay: Overlay::None,
help_scroll: crate::tui::scroll::Scroll::default(),
help_query: InputField::new(""),
status_msg: None,
loading: None,
loading_label: REFRESH_LABEL,
loading_detail: None,
loading_name_width: 0,
zip_rx: None,
zip_backups: HashMap::new(),
cache_generated_at: cached.generated_at,
last_fetched: cached.fetched_at,
status_jobs: Vec::new(),
refreshing: HashSet::new(),
spinner: Spinner::new(),
spinner_at: Instant::now(),
selected: HashSet::new(),
anchor: None,
list_offset: std::cell::Cell::new(0),
table_offset: std::cell::Cell::new(0),
auto_refresh: false,
refreshed_tabs: HashSet::new(),
files_missing: HashSet::new(),
show_slugs: ui.show_slugs,
changes_only: false,
tab_focus: HashMap::new(),
list_height: std::cell::Cell::new(1),
preview: PreviewLayout::from_state(
&ui.preview,
ui.preview_width_pct,
ui.preview_height_rows,
),
preview_scroll: crate::tui::scroll::Scroll::default(),
stats_path,
stats,
code_rx: None,
git_stats_rx: None,
computing: HashSet::new(),
preview_log: HashMap::new(),
preview_tx,
preview_rx,
preview_pending: HashSet::new(),
preview_target: None,
preview_target_at: Instant::now(),
};
app.reload_zip_backups();
app.start_stats();
if let StartupStatus::Refresh { fetch } = startup
&& !app.config.example_mode
{
app.auto_refresh = true;
if app.tab.kind() == TabKind::Git {
app.start_refresh(fetch);
}
}
app.refresh_tab_on_first_visit();
app
}
fn set_status(&mut self, message: impl Into<String>) {
self.status_msg = Some((message.into(), Instant::now()));
}
fn tick(&mut self) {
if let Some((_, at)) = &self.status_msg
&& at.elapsed() > STATUS_TTL
{
self.status_msg = None;
}
if !self.refreshing.is_empty()
&& self.spinner_at.elapsed() >= SPINNER_INTERVAL
{
self.spinner.advance();
self.spinner_at = Instant::now();
}
}
}
#[cfg(test)]
mod tests;