gitpane 0.16.1

Multi-repo Git workspace dashboard TUI
Documentation
use super::*;

impl App {
    pub(super) fn refresh_shared_membership(&mut self) -> Result<()> {
        self.last_config_check = Some(Instant::now());
        if self.config.reload_membership()? {
            self.handle_repo_admin(Action::DiscoverNewRepos)?;
            self.action_tx.send(Action::Render)?;
        }
        Ok(())
    }

    pub(super) fn sync_shared_config(&mut self) {
        if let Err(e) = self.refresh_shared_membership() {
            self.error_message = Some((format!("config sync failed: {e}"), Instant::now()));
            let _ = self.action_tx.send(Action::Render);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn setup() -> (tempfile::TempDir, App, App) {
        let dir = tempfile::tempdir().unwrap();
        let mut config = Config {
            root_dirs: vec![],
            write_target_override: Some(dir.path().join("config.toml")),
            ..Config::default()
        };
        config.save().unwrap();
        let first = App::new(config.clone());
        let second = App::new(config);
        (dir, first, second)
    }

    #[tokio::test]
    async fn another_pane_receives_additions_and_removals_on_tick() {
        let (dir, mut first, mut second) = setup();
        let path = dir.path().join("repo");
        git2::Repository::init(&path).unwrap();
        let path = path.canonicalize().unwrap();
        first
            .handle_repo_admin(Action::AddRepo(path.clone()))
            .unwrap();
        second.handle_event(Event::Tick).unwrap();
        assert_eq!(second.repo_list.repos[0].path, path);
        first
            .handle_repo_admin(Action::RemoveRepo(RepoId(path)))
            .unwrap();
        second.last_config_check = None;
        second.handle_event(Event::Tick).unwrap();
        assert!(second.repo_list.repos.is_empty());
        assert!(second.repo_list.selected_repo().is_none());
    }

    #[tokio::test]
    async fn removal_of_discovered_repo_propagates_on_focus() {
        let (dir, mut first, mut second) = setup();
        let path = dir.path().join("repo");
        git2::Repository::init(&path).unwrap();
        let path = path.canonicalize().unwrap();
        for app in [&mut first, &mut second] {
            app.config.root_dirs = vec![dir.path().to_path_buf()];
            app.handle_repo_admin(Action::DiscoverNewRepos).unwrap();
        }
        first
            .handle_repo_admin(Action::RemoveRepo(RepoId(path)))
            .unwrap();
        second.handle_event(Event::FocusGained).unwrap();
        assert!(second.repo_list.repos.is_empty());
        assert!(crate::git::scanner::discover_repos(&second.config).is_empty());
    }

    #[tokio::test]
    async fn sleeping_pane_refreshes_shared_membership_on_wake() {
        let (dir, mut first, mut second) = setup();
        let path = dir.path().join("repo");
        git2::Repository::init(&path).unwrap();
        second
            .handle_event(Event::Power(PowerState::DeepSleep))
            .unwrap();
        first
            .handle_repo_admin(Action::AddRepo(path.clone()))
            .unwrap();
        assert!(second.repo_list.repos.is_empty());
        second
            .handle_event(Event::Power(PowerState::Awake))
            .unwrap();
        assert_eq!(second.repo_list.repos[0].path, path.canonicalize().unwrap());
    }

    #[tokio::test]
    async fn add_refreshes_stale_membership_before_saving() {
        let (dir, mut first, mut second) = setup();
        let a = dir.path().join("a");
        let b = dir.path().join("b");
        git2::Repository::init(&a).unwrap();
        git2::Repository::init(&b).unwrap();
        first.handle_repo_admin(Action::AddRepo(a.clone())).unwrap();
        second
            .handle_repo_admin(Action::AddRepo(b.clone()))
            .unwrap();
        first.handle_event(Event::FocusGained).unwrap();
        for app in [&first, &second] {
            assert!(
                app.repo_list
                    .repos
                    .iter()
                    .any(|r| r.path == a.canonicalize().unwrap())
            );
            assert!(
                app.repo_list
                    .repos
                    .iter()
                    .any(|r| r.path == b.canonicalize().unwrap())
            );
        }
    }

    #[tokio::test]
    async fn malformed_shared_config_blocks_add_and_surfaces_error() {
        let (dir, mut first, _) = setup();
        let path = dir.path().join("repo");
        git2::Repository::init(&path).unwrap();
        std::fs::write(dir.path().join("config.toml"), "bad = [").unwrap();
        first.handle_repo_admin(Action::AddRepo(path)).unwrap();
        assert!(first.repo_list.repos.is_empty());
        assert!(
            first
                .error_message
                .as_ref()
                .unwrap()
                .0
                .contains("config sync failed")
        );
    }

    #[tokio::test]
    async fn opted_out_pane_keeps_its_view_even_when_saving_changes() {
        for cli_override in [false, true] {
            let (dir, mut first, mut second) = setup();
            if cli_override {
                second.config.runtime_no_sync_repos = true;
            } else {
                second.config.ui.sync_repos = false;
            }
            let a = dir.path().join("a");
            let b = dir.path().join("b");
            git2::Repository::init(&a).unwrap();
            git2::Repository::init(&b).unwrap();
            first.handle_repo_admin(Action::AddRepo(a.clone())).unwrap();
            second.handle_event(Event::Tick).unwrap();
            second.handle_event(Event::FocusGained).unwrap();
            assert!(second.repo_list.repos.is_empty());
            second
                .handle_repo_admin(Action::AddRepo(b.clone()))
                .unwrap();
            assert_eq!(second.repo_list.repos.len(), 1);
            assert_eq!(second.repo_list.repos[0].path, b.canonicalize().unwrap());
            first.handle_event(Event::FocusGained).unwrap();
            assert_eq!(first.repo_list.repos.len(), 2);
        }
    }

    #[tokio::test]
    async fn synchronization_preserves_selected_repo_and_status() {
        let (dir, mut first, mut second) = setup();
        let b = dir.path().join("b");
        let a = dir.path().join("a");
        git2::Repository::init(&a).unwrap();
        git2::Repository::init(&b).unwrap();
        first.handle_repo_admin(Action::AddRepo(b.clone())).unwrap();
        second.handle_event(Event::FocusGained).unwrap();
        second.repo_list.select_repo_row(0);
        std::fs::write(b.join("untracked.txt"), "keep this status").unwrap();
        second.repo_list.repos[0].status =
            Some(crate::git::status::query_status(&b, &second.config.submodules).unwrap());
        first.handle_repo_admin(Action::AddRepo(a)).unwrap();
        second.handle_event(Event::FocusGained).unwrap();
        assert_eq!(
            second.repo_list.selected_repo().unwrap().path,
            b.canonicalize().unwrap()
        );
        assert!(
            second
                .repo_list
                .selected_repo()
                .unwrap()
                .status
                .as_ref()
                .unwrap()
                .is_dirty
        );
    }
}