use std::time::Duration;
use color_eyre::eyre::{Result, eyre};
use repon_core::{Core, CoreSpec, SetSpec};
use super::App;
use crate::{
components::Component,
config::{
Config,
document::{self, Document},
},
glyphs::GlyphSet,
keys, theme,
};
pub(crate) const GENERATION_DEADLINE: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ActiveSet {
pub(crate) name: String,
pub(crate) roots: Vec<String>,
pub(crate) include: Option<Vec<String>>,
pub(crate) exclude: Option<Vec<String>>,
}
impl ActiveSet {
pub(crate) fn from_config(set: &document::SetConfig) -> Self {
Self {
name: set.name.get_ref().clone(),
roots: set.roots.clone(),
include: set.include.clone(),
exclude: set.exclude.clone(),
}
}
}
pub(crate) fn resolve_startup_set<'a>(
sets: &'a [document::SetConfig],
flag: Option<&str>,
env: Option<&str>,
remembered: Option<&str>,
) -> Result<&'a document::SetConfig> {
if let Some(name) = flag {
return sets
.iter()
.find(|set| set.name.get_ref() == name)
.ok_or_else(|| eyre!("--set `{name}` names no declared Set; see `repon sets`"));
}
if let Some(name) = env {
return sets
.iter()
.find(|set| set.name.get_ref() == name)
.ok_or_else(|| eyre!("REPON_SET `{name}` names no declared Set; see `repon sets`"));
}
if let Some(set) =
remembered.and_then(|name| sets.iter().find(|set| set.name.get_ref() == name))
{
return Ok(set);
}
Ok(sets
.first()
.expect("Document::load always leaves at least one Set, `all` if none was declared"))
}
fn switched_to_notice(name: &str) -> String {
format!("switched to `{name}`")
}
fn no_such_set_notice(declared: usize) -> String {
let plural = if declared == 1 { "" } else { "s" };
format!("only {declared} Set{plural} declared; press s to pick one")
}
pub(crate) fn action_running_notice(what: &str) -> String {
format!("{what}: Action already running")
}
impl App {
pub(crate) fn reload_config(&mut self) {
if let Err(err) = crate::config::check_named_paths_exist(&self.named_config_paths) {
tracing::error!("config reload failed, keeping the previous configuration: {err:#}");
return;
}
let new_config = match Config::at(self.config_dir.clone(), self.config_file.clone()) {
Ok(config) => config,
Err(err) => {
tracing::error!(
"config reload failed, keeping the previous configuration: {err:#}"
);
return;
}
};
self.apply_reloaded_config(new_config);
}
fn apply_reloaded_config(&mut self, new_config: Config) {
let (bindings, keys_warnings) = match keys::merge(&new_config.document.keys) {
Ok(result) => result,
Err(err) => {
tracing::error!(
"config reload failed to merge [keys], keeping the previous keyboard: {err:#}"
);
return;
}
};
for warning in &keys_warnings {
tracing::warn!("{warning}");
}
self.bindings = bindings;
let theme_name = new_config.document.theme.clone();
match theme::load(&self.themes_dir, &theme_name, theme::ThemeSource::Config) {
Ok(loaded_theme) => {
for warning in &loaded_theme.warnings {
tracing::warn!("{warning}");
}
self.theme = loaded_theme.theme;
self.theme_warnings = loaded_theme.warnings;
self.theme_name = theme_name;
self.theme_source = theme::ThemeSource::Config;
}
Err(err) => {
tracing::error!("config reload failed to load theme `{theme_name}`: {err:#}");
}
}
self.glyphs = GlyphSet::for_config(new_config.document.glyphs);
if let Err(err) = self.list.register_config_handler(new_config.clone()) {
tracing::error!("config reload failed to hand the new config to a component: {err:#}");
}
self.config_warnings = new_config.warnings;
self.reload_active_set(&new_config.document);
self.document = new_config.document;
self.worktrees_toggle = None;
self.core.set_show_submodules(self.document.show_submodules);
self.core
.set_exclusions(&document::repo_overrides(&self.document));
self.follow_cursor();
}
fn reload_active_set(&mut self, document: &Document) {
let fallback = document
.sets
.first()
.expect("Document::load always leaves at least one Set, `all` if none was declared");
let chosen = document
.sets
.iter()
.find(|set| set.name.get_ref() == &self.active_set.name)
.unwrap_or(fallback);
if chosen.name.get_ref() != &self.active_set.name {
tracing::warn!(
"the active Set `{}` no longer exists; falling back to `{}`",
self.active_set.name,
chosen.name.get_ref(),
);
self.set_notice(switched_to_notice(chosen.name.get_ref()));
}
self.apply_active_set(chosen, document);
}
pub(crate) fn switch_to_set(&mut self, nth: u8) -> bool {
if self.any_run_outstanding() {
self.set_notice(action_running_notice("Set switch"));
return false;
}
let document = self.document.clone();
let index = usize::from(nth).wrapping_sub(1);
match document.sets.get(index) {
Some(chosen) => {
self.apply_active_set(chosen, &document);
self.set_notice(switched_to_notice(chosen.name.get_ref()));
true
}
None => {
self.set_notice(no_such_set_notice(document.sets.len()));
false
}
}
}
fn apply_active_set(&mut self, chosen: &document::SetConfig, document: &Document) {
let resolved = ActiveSet::from_config(chosen);
let bounds_changed = resolved.roots != self.active_set.roots
|| resolved.include != self.active_set.include
|| resolved.exclude != self.active_set.exclude;
self.active_set = resolved;
if bounds_changed {
self.core = Core::start(core_spec(document, &self.active_set, self.no_fetch));
self.core.refresh_all();
self.discovery_warning_logged = false;
self.fetch_failures_logged = repon_core::FetchFailures::default();
}
self.follow_cursor();
}
}
pub(crate) fn core_spec(document: &Document, active_set: &ActiveSet, no_fetch: bool) -> CoreSpec {
CoreSpec {
set: SetSpec {
name: active_set.name.clone(),
roots: active_set
.roots
.iter()
.map(|root| document::expand_home(root))
.collect(),
include: active_set.include.clone().unwrap_or_default(),
exclude: active_set.exclude.clone().unwrap_or_default(),
},
overrides: document::repo_overrides(document),
poll_interval: document.refresh.poll_interval,
status_stale_after: document.refresh.status_stale_after,
generation_deadline: GENERATION_DEADLINE,
show_submodules: document.show_submodules,
fetch: repon_core::FetchSpec {
enabled: document.fetch.enabled && !no_fetch,
interval: document.fetch.interval,
concurrency: document.fetch.concurrency as usize,
},
auto_update: repon_core::AutoUpdateSpec {
enabled: document.auto_update.enabled,
},
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::layout::Size;
use repon_core::liveness::wait_for;
use super::*;
use crate::{
app::tests::{init_repo, press, render_app_frame, test_app, write_gitmodules},
keys::Context,
test_support::capture_tracing,
};
fn matching_set_config(root: &std::path::Path) -> document::SetConfig {
document::SetConfig {
name: toml::Spanned::new(0..0, "test".to_string()),
roots: vec![root.to_string_lossy().into_owned()],
include: None,
exclude: None,
on_refresh: None,
before_sync: None,
after_sync: None,
}
}
fn config_with_document(document: Document) -> Config {
Config {
config_dir: std::path::PathBuf::new(),
data_dir: std::path::PathBuf::new(),
document,
warnings: Vec::new(),
zero_config: false,
}
}
#[test]
fn reload_rebinds_the_live_table_and_the_footer_reflects_it_immediately_with_no_restart() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo(&root.join("repo-a"));
let mut app = test_app(&root);
let before = crate::footer::render(&app.bindings, Context::List, 87);
assert!(
before.contains("? help"),
"expected the compiled default's help hint, got: {before:?}"
);
let mut open_help_rebind = toml::Table::new();
open_help_rebind.insert(
"open_help".to_string(),
toml::Value::String("x".to_string()),
);
let mut keys_block = toml::Table::new();
keys_block.insert("global".to_string(), toml::Value::Table(open_help_rebind));
let mut document = Document {
keys: keys_block,
..Document::default()
};
document.sets.push(matching_set_config(&root));
app.apply_reloaded_config(config_with_document(document));
let after = crate::footer::render(&app.bindings, Context::List, 87);
assert!(
after.contains("x help"),
"expected the rebound help hint in the footer with no restart, got: {after:?}"
);
assert!(
!after.contains("? help"),
"the old help hint must not still render once it has been rebound, got: {after:?}"
);
}
#[test]
fn toggling_show_submodules_through_reload_reflows_the_viewport_under_a_standing_cursor() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
let parent = root.join("parent");
init_repo(&parent);
write_gitmodules(&parent, "lib", "vendor/lib");
std::fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
for i in 0..4 {
init_repo(&root.join(format!("repo-{i}")));
}
let mut app = test_app(&root);
app.document.show_submodules = true;
app.core.set_show_submodules(true);
app.frame_size = Size::new(140, 8);
assert_eq!(app.list_viewport_rows(), 3);
assert_eq!(app.visible_keys().len(), 6);
app.handle_key_event(press(KeyCode::Char('G'), KeyModifiers::SHIFT))
.expect("dispatch G");
assert_eq!(app.cursor, 5);
assert_eq!(app.list_offset, 3);
let mut document = Document {
show_submodules: false,
..Document::default()
};
document.sets.push(matching_set_config(&root));
app.apply_reloaded_config(config_with_document(document));
assert_eq!(
app.visible_keys().len(),
5,
"the submodule must be hidden again once the reload turns show_submodules off"
);
assert_eq!(
app.list_offset, 2,
"the standing cursor (5) is now past the narrowed table's own end (5 rows); the \
offset must clamp to the largest window that still describes real rows: [2, 5)"
);
}
#[test]
fn a_reload_clears_the_worktrees_toggle_back_to_whatever_the_file_currently_says() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo(&root.join("repo-a"));
let mut app = test_app(&root);
assert!(
app.effective_show_worktrees(),
"Document::default's own starting value"
);
app.toggle_worktrees();
assert!(
!app.effective_show_worktrees(),
"the toggle just turned Worktrees off"
);
let mut document = Document::default();
document.sets.push(matching_set_config(&root));
app.apply_reloaded_config(config_with_document(document));
assert!(
app.effective_show_worktrees(),
"a reload must clear the session override and fall back to the freshly-loaded \
`show_worktrees = true` again"
);
}
#[test]
fn a_save_right_after_a_reload_records_the_toggles_absence_not_its_last_value() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo(&root.join("repo-a"));
let state_dir = tempfile::tempdir().expect("state temp dir");
let mut app = test_app(&root);
app.data_dir = state_dir.path().to_path_buf();
app.toggle_worktrees();
app.persist_state();
let mut document = Document::default();
document.sets.push(matching_set_config(&root));
app.apply_reloaded_config(config_with_document(document));
app.persist_state();
let mut app_again = test_app(&root);
app_again.data_dir = state_dir.path().to_path_buf();
app_again.restore_session_state(None);
assert!(
app_again.effective_show_worktrees(),
"the save after the reload must have overwritten the earlier `Some(false)` with \
`None`, so a restart now defers to `config.toml`'s own `show_worktrees = true`"
);
}
#[test]
fn a_change_to_the_active_sets_roots_discards_discovery_and_starts_a_fresh_generation() {
let dir_a = tempfile::tempdir().expect("temp dir a");
let root_a = dir_a
.path()
.canonicalize()
.expect("canonicalize temp dir a");
init_repo(&root_a.join("repo-a"));
let dir_b = tempfile::tempdir().expect("temp dir b");
let root_b = dir_b
.path()
.canonicalize()
.expect("canonicalize temp dir b");
init_repo(&root_b.join("repo-b"));
let mut app = test_app(&root_a);
let before_names: Vec<String> = app
.core
.snapshot()
.entities
.iter()
.map(|entity| entity.name.to_string())
.collect();
assert!(
before_names.iter().any(|name| name == "repo-a"),
"expected repo-a discovered under the first root, got {before_names:?}"
);
let mut document = Document::default();
document.sets.push(document::SetConfig {
name: toml::Spanned::new(0..0, "test".to_string()),
roots: vec![root_b.to_string_lossy().into_owned()],
include: None,
exclude: None,
on_refresh: None,
before_sync: None,
after_sync: None,
});
app.reload_active_set(&document);
wait_for("the rebuilt Core's own discovery to land", || {
app.core
.snapshot()
.entities
.iter()
.any(|entity| &*entity.name == "repo-b")
});
let after_names: Vec<String> = app
.core
.snapshot()
.entities
.iter()
.map(|entity| entity.name.to_string())
.collect();
assert!(
after_names.iter().any(|name| name == "repo-b"),
"expected discovery to re-run over the new root, got {after_names:?}"
);
assert!(
!after_names.iter().any(|name| name == "repo-a"),
"expected the old root's discovery to be discarded, got {after_names:?}"
);
}
#[test]
fn reload_with_the_same_active_set_leaves_discovery_and_its_generation_untouched() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo(&root.join("repo-a"));
let mut app = test_app(&root);
let keys: Vec<_> = app
.core
.snapshot()
.entities
.iter()
.map(|entity| entity.key.clone())
.collect();
app.core.refresh(&keys);
app.core.refresh(&keys);
let before = app.core.snapshot().generation;
let mut document = Document::default();
document.sets.push(matching_set_config(&root));
app.reload_active_set(&document);
assert_eq!(
app.core.snapshot().generation,
before,
"an unchanged Set must not rebuild Core or start a new Generation"
);
assert_eq!(
app.notice(),
None,
"an ordinary reload that names the same Set must raise no Notice, unlike the \
vanished-Set fallback below"
);
}
#[test]
fn a_vanished_active_set_falls_back_to_the_first_declared_set_and_announces_it() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo(&root.join("repo-a"));
let mut app = test_app(&root);
assert_eq!(app.active_set.name, "test");
let mut document = Document::default();
document.sets.push(document::SetConfig {
name: toml::Spanned::new(0..0, "renamed".to_string()),
roots: vec![root.to_string_lossy().into_owned()],
include: None,
exclude: None,
on_refresh: None,
before_sync: None,
after_sync: None,
});
let logs = capture_tracing(|| app.reload_active_set(&document));
assert_eq!(
app.active_set.name, "renamed",
"expected the fallback to the first declared Set"
);
assert!(
logs.contains("test") && logs.contains("renamed"),
"expected the fallback announced naming both the vanished and the new Set, got: {logs:?}"
);
assert_eq!(
app.notice(),
Some("switched to `renamed`"),
"expected the same Notice `switch_to_set` raises, naming the Set fallen back to, \
rather than only the log line above"
);
}
fn named_set(name: &str) -> document::SetConfig {
document::SetConfig {
name: toml::Spanned::new(0..0, name.to_string()),
roots: vec!["/dev/null".to_string()],
include: None,
exclude: None,
on_refresh: None,
before_sync: None,
after_sync: None,
}
}
#[test]
fn the_flag_beats_a_real_environment_value_and_the_first_declared_set() {
let sets = vec![named_set("alpha"), named_set("beta"), named_set("gamma")];
let chosen =
resolve_startup_set(&sets, Some("gamma"), Some("beta"), None).expect("gamma exists");
assert_eq!(chosen.name.get_ref(), "gamma");
}
#[test]
fn the_environment_variable_beats_the_first_declared_set_when_no_flag_is_given() {
let sets = vec![named_set("alpha"), named_set("beta")];
let chosen = resolve_startup_set(&sets, None, Some("beta"), None).expect("beta exists");
assert_eq!(chosen.name.get_ref(), "beta");
}
#[test]
fn the_remembered_set_beats_the_first_declared_set_when_no_flag_and_no_environment_value_is_given()
{
let sets = vec![named_set("alpha"), named_set("beta"), named_set("gamma")];
let chosen =
resolve_startup_set(&sets, None, None, Some("gamma")).expect("gamma is declared");
assert_eq!(chosen.name.get_ref(), "gamma");
}
#[test]
fn the_flag_beats_the_remembered_set() {
let sets = vec![named_set("alpha"), named_set("beta"), named_set("gamma")];
let chosen = resolve_startup_set(&sets, Some("beta"), None, Some("gamma"))
.expect("beta is declared");
assert_eq!(chosen.name.get_ref(), "beta");
}
#[test]
fn the_environment_variable_beats_the_remembered_set() {
let sets = vec![named_set("alpha"), named_set("beta"), named_set("gamma")];
let chosen = resolve_startup_set(&sets, None, Some("beta"), Some("gamma"))
.expect("beta is declared");
assert_eq!(chosen.name.get_ref(), "beta");
}
#[test]
fn a_remembered_set_that_is_no_longer_declared_falls_through_to_the_first_declared_set() {
let sets = vec![named_set("alpha"), named_set("beta")];
let chosen = resolve_startup_set(&sets, None, None, Some("deleted-since"))
.expect("a vanished remembered Set is not an error");
assert_eq!(chosen.name.get_ref(), "alpha");
}
#[test]
fn the_first_declared_set_wins_with_no_flag_and_no_environment_value() {
let sets = vec![named_set("alpha"), named_set("beta"), named_set("gamma")];
let chosen =
resolve_startup_set(&sets, None, None, None).expect("a Set is always declared here");
assert_eq!(chosen.name.get_ref(), "alpha");
}
#[test]
fn the_implicit_set_wins_when_none_is_declared_and_neither_flag_nor_environment_is_given() {
let loaded = document::load(Path::new("/does/not/exist/anywhere/repon-config.toml"))
.expect("a missing file is not an error");
let chosen = resolve_startup_set(&loaded.document.sets, None, None, None)
.expect("the implicit `all` Set is always declared here");
assert_eq!(chosen.name.get_ref(), "all");
}
#[test]
fn an_unmatched_flag_is_an_error_naming_the_flag_and_value_and_never_falls_through_to_a_real_environment_value()
{
let sets = vec![named_set("alpha"), named_set("beta")];
let err = resolve_startup_set(&sets, Some("nonexistent"), Some("beta"), None)
.expect_err("an unmatched --set must be an error, not a fallback");
let message = err.to_string();
assert!(
message.contains("--set"),
"expected the flag named in the message, got: {message:?}"
);
assert!(
message.contains("nonexistent"),
"expected the offending value named in the message, got: {message:?}"
);
assert!(
message.contains("repon sets"),
"expected the message to point at `repon sets`, got: {message:?}"
);
}
#[test]
fn an_unmatched_environment_variable_is_an_error_naming_the_variable_and_value() {
let sets = vec![named_set("alpha"), named_set("beta")];
let err = resolve_startup_set(&sets, None, Some("nonexistent"), None)
.expect_err("an unmatched REPON_SET must be an error");
let message = err.to_string();
assert!(
message.contains("REPON_SET"),
"expected the variable named in the message, got: {message:?}"
);
assert!(
message.contains("nonexistent"),
"expected the offending value named in the message, got: {message:?}"
);
assert!(
message.contains("repon sets"),
"expected the message to point at `repon sets`, got: {message:?}"
);
}
#[test]
fn switching_to_a_different_declared_set_discards_discovery_and_starts_a_fresh_generation() {
let dir_a = tempfile::tempdir().expect("temp dir a");
let root_a = dir_a
.path()
.canonicalize()
.expect("canonicalize temp dir a");
init_repo(&root_a.join("repo-a"));
let dir_b = tempfile::tempdir().expect("temp dir b");
let root_b = dir_b
.path()
.canonicalize()
.expect("canonicalize temp dir b");
init_repo(&root_b.join("repo-b"));
let mut app = test_app(&root_a);
app.document.sets = vec![
matching_set_config(&root_a),
document::SetConfig {
name: toml::Spanned::new(0..0, "second".to_string()),
roots: vec![root_b.to_string_lossy().into_owned()],
include: None,
exclude: None,
on_refresh: None,
before_sync: None,
after_sync: None,
},
];
app.switch_to_set(2);
wait_for("the rebuilt Core's own discovery to land", || {
app.core
.snapshot()
.entities
.iter()
.any(|entity| &*entity.name == "repo-b")
});
let after_names: Vec<String> = app
.core
.snapshot()
.entities
.iter()
.map(|entity| entity.name.to_string())
.collect();
assert!(
after_names.iter().any(|name| name == "repo-b"),
"expected discovery to re-run over the second Set's root, got {after_names:?}"
);
assert!(
!after_names.iter().any(|name| name == "repo-a"),
"expected the first Set's discovery to be discarded, got {after_names:?}"
);
assert_eq!(
app.notice(),
Some("switched to `second`"),
"expected a Notice naming the Set switched to"
);
}
#[test]
fn a_notice_lasts_until_the_next_press_so_it_cannot_hide_the_warning_slot_for_the_run() {
let dir_a = tempfile::tempdir().expect("temp dir a");
let root_a = dir_a
.path()
.canonicalize()
.expect("canonicalize temp dir a");
init_repo(&root_a.join("repo-a"));
let dir_b = tempfile::tempdir().expect("temp dir b");
let root_b = dir_b
.path()
.canonicalize()
.expect("canonicalize temp dir b");
init_repo(&root_b.join("repo-b"));
let mut app = test_app(&root_a);
app.document.sets = vec![
matching_set_config(&root_a),
document::SetConfig {
name: toml::Spanned::new(0..0, "second".to_string()),
roots: vec![root_b.to_string_lossy().into_owned()],
include: None,
exclude: None,
on_refresh: None,
before_sync: None,
after_sync: None,
},
];
app.handle_key_event(press(KeyCode::Char('2'), KeyModifiers::NONE))
.expect("switch to the second Set");
assert_eq!(
app.notice(),
Some("switched to `second`"),
"the press that switches Sets must answer with a Notice"
);
app.handle_key_event(press(KeyCode::Char('j'), KeyModifiers::NONE))
.expect("move the cursor");
assert_eq!(
app.notice(),
None,
"a Notice that survives the next press displaces the warning slot for the rest of \
the run"
);
}
#[test]
fn switching_to_the_already_active_set_leaves_discovery_and_its_generation_untouched() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo(&root.join("repo-a"));
let mut app = test_app(&root);
app.document.sets = vec![matching_set_config(&root)];
let keys: Vec<_> = app
.core
.snapshot()
.entities
.iter()
.map(|entity| entity.key.clone())
.collect();
app.core.refresh(&keys);
app.core.refresh(&keys);
let before = app.core.snapshot().generation;
app.switch_to_set(1);
assert_eq!(
app.core.snapshot().generation,
before,
"switching to the already-active Set must not rebuild Core or start a new Generation"
);
assert_eq!(
app.notice(),
Some("switched to `test`"),
"expected a Notice naming the Set even though it was already active"
);
}
#[test]
fn switching_past_the_last_declared_set_is_a_no_op_that_raises_a_notice() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo(&root.join("repo-a"));
let mut app = test_app(&root);
app.document.sets = vec![matching_set_config(&root)];
let before = app.active_set.clone();
app.switch_to_set(9);
assert_eq!(app.active_set, before);
assert_eq!(
app.notice(),
Some("only 1 Set declared; press s to pick one"),
"expected a Notice naming the declared count in the singular"
);
}
#[test]
fn switching_past_the_last_declared_set_names_the_plural_count() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo(&root.join("repo-a"));
let mut app = test_app(&root);
app.document.sets = vec![
matching_set_config(&root),
document::SetConfig {
name: toml::Spanned::new(0..0, "second".to_string()),
roots: vec![root.to_string_lossy().into_owned()],
include: None,
exclude: None,
on_refresh: None,
before_sync: None,
after_sync: None,
},
];
app.switch_to_set(9);
assert_eq!(
app.notice(),
Some("only 2 Sets declared; press s to pick one")
);
}
fn slow_action_spec() -> repon_core::ActionSpec {
repon_core::ActionSpec {
label: std::sync::Arc::from("slow"),
name: Some(std::sync::Arc::from("slow")),
steps: vec![repon_core::Step {
argv: vec!["sh".to_string(), "-c".to_string(), "sleep 1".to_string()],
shell: false,
interactive: false,
env: Vec::new(),
}],
concurrency: 1,
when: None,
}
}
#[test]
fn switching_sets_while_an_action_is_fanning_out_answers_with_a_notice_and_leaves_the_active_set_untouched()
{
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo(&root.join("repo-a"));
let mut app = test_app(&root);
app.document.sets = vec![
matching_set_config(&root),
document::SetConfig {
name: toml::Spanned::new(0..0, "second".to_string()),
roots: vec![root.to_string_lossy().into_owned()],
include: None,
exclude: None,
on_refresh: None,
before_sync: None,
after_sync: None,
},
];
let keys: Vec<_> = app
.core
.snapshot()
.entities
.iter()
.map(|entity| entity.key.clone())
.collect();
assert!(
app.core.run_action(slow_action_spec(), &keys),
"sanity: the fan-out must actually have started"
);
let active_before = app.active_set.clone();
app.switch_to_set(2);
assert_eq!(
app.active_set, active_before,
"an inert digit must never move the active Set while a fan-out is live"
);
assert_eq!(
app.notice(),
Some("Set switch: Action already running"),
"expected a Notice naming the run in progress rather than silence or a real switch"
);
wait_for(
"the fan-out to finish before this test's own Core is dropped",
|| !app.core.action_running(),
);
}
#[test]
fn switch_to_set_computes_its_refusal_reason_at_the_point_of_refusal_not_fixed_per_action() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo(&root.join("repo-a"));
let mut app = test_app(&root);
app.document.sets = vec![matching_set_config(&root)];
app.switch_to_set(9);
let reason_a = app
.notice()
.expect("expected a Notice for an out-of-range digit")
.to_string();
let keys: Vec<_> = app
.core
.snapshot()
.entities
.iter()
.map(|entity| entity.key.clone())
.collect();
assert!(app.core.run_action(slow_action_spec(), &keys));
app.switch_to_set(1);
let reason_b = app
.notice()
.expect("expected a Notice for a live fan-out")
.to_string();
assert_ne!(
reason_a, reason_b,
"the same action refused for two different reasons must answer with two \
different texts, not one fixed string for SwitchToSet as a whole"
);
wait_for(
"the fan-out to finish before this test's own Core is dropped",
|| !app.core.action_running(),
);
}
#[test]
fn every_action_running_notice_this_crate_actually_raises_fits_44_columns() {
for what in [
"Action palette",
"Set picker",
"Reload config",
"Set switch",
"Edit config",
] {
let text = action_running_notice(what);
assert!(
!text.is_empty(),
"expected a real reason, not an empty string"
);
assert!(
text.len() <= 44,
"{text:?} is {} columns, over the 44-column budget",
text.len()
);
}
}
#[test]
fn switched_to_notice_fits_44_columns_for_a_generously_long_set_name() {
let text = switched_to_notice(&"x".repeat(20));
assert!(
!text.is_empty(),
"expected a real reason, not an empty string"
);
assert!(
text.len() <= 44,
"{text:?} is {} columns, over the 44-column budget",
text.len()
);
}
#[test]
fn no_such_set_notice_fits_44_columns_for_a_generously_large_declared_count() {
let text = no_such_set_notice(999);
assert!(
!text.is_empty(),
"expected a real reason, not an empty string"
);
assert!(
text.len() <= 44,
"{text:?} is {} columns, over the 44-column budget",
text.len()
);
}
#[test]
fn the_glyphs_key_re_applies_on_reload_for_the_picker_as_well_as_the_list() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo(&root.join("repo-a"));
let mut app = test_app(&root);
let (width, height) = (60u16, 16u16);
assert_eq!(
app.glyphs,
&crate::glyphs::FULL,
"sanity: the run starts on the full table"
);
let mut reloaded = app.document.clone();
reloaded.glyphs = document::Glyphs::Ascii;
reloaded.sets = vec![matching_set_config(&root)];
app.apply_reloaded_config(config_with_document(reloaded));
let ascii = crate::glyphs::ASCII.border;
let picker = crate::set_picker::SetPicker::default();
let popup = picker.popup_area(
ratatui::layout::Rect::new(0, 0, width, height),
&app.document.sets,
&app.active_set.name,
);
app.set_picker = Some(picker);
let buf = render_app_frame(&mut app, width, height);
crate::test_support::assert_frame_drawn_with(
&buf,
popup,
ascii,
crate::set_picker::BORDER_TITLE,
"the Set picker after a reload to `ascii`",
);
app.set_picker = None;
let buf = render_app_frame(&mut app, width, height);
crate::test_support::assert_bordered_frame_and_top_title_drawn_with(
&buf,
ratatui::layout::Rect::new(0, 1, width, height - 2),
ascii,
" repos ",
"the list pane after a reload to `ascii`",
);
}
#[test]
fn notice_timeout_re_applies_immediately_on_reload_with_no_new_press() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo(&root.join("repo-a"));
let mut app = test_app(&root);
app.document.notice_timeout = Duration::from_secs(3600);
app.set_notice("switched to `second`".to_string());
app.notice_set_at = Some(std::time::Instant::now() - Duration::from_secs(10));
assert_eq!(
app.notice(),
Some("switched to `second`"),
"sanity: still live under the long timeout ten seconds in"
);
let mut reloaded_document = app.document.clone();
reloaded_document.notice_timeout = Duration::from_secs(1);
app.apply_reloaded_config(config_with_document(reloaded_document));
assert_eq!(
app.notice(),
None,
"the shorter reloaded timeout must age out the Notice already on screen, with no \
new press"
);
}
fn active_set_for_fetch_test() -> ActiveSet {
ActiveSet {
name: "test".to_string(),
roots: vec!["/dev/null".to_string()],
include: None,
exclude: None,
}
}
#[test]
fn the_no_fetch_flag_forces_fetch_disabled_even_when_the_document_enables_it() {
let mut document = Document::default();
document.fetch.enabled = true;
let spec = core_spec(&document, &active_set_for_fetch_test(), true);
assert!(
!spec.fetch.enabled,
"expected --no-fetch to force fetch.enabled off"
);
}
#[test]
fn fetch_enabled_in_the_document_passes_through_unchanged_when_no_fetch_is_absent() {
let mut document = Document::default();
document.fetch.enabled = true;
let spec = core_spec(&document, &active_set_for_fetch_test(), false);
assert!(
spec.fetch.enabled,
"expected fetch.enabled to pass through unchanged when --no-fetch is absent"
);
}
}