cargo-port 0.2.1

A TUI for inspecting and managing Rust projects
//! The `Panes` subsystem.
//!
//! Owns the pane-related state cluster (`pane_data`, `visited_panes`,
//! `hovered_pane_row`, plus the per-pane structs in their owning pane modules).
//! Exposes a facade so App's impl-files and the `panes/` siblings
//! don't reach into App's private guts directly.
//!
//! `handle_input`-style methods that need cross-subsystem access
//! remain free functions taking `&mut App`.

use std::time::Instant;

use tui_pane::ResolvedPaneLayout;

use super::CpuPane;
use super::GitPane;
use super::LangPane;
use super::OutputPane;
use super::PackagePane;
use super::ProjectListPane;
use super::TargetsPane;
use super::constants::RUNNING_TARGETS_POLL_INTERVAL;
use super::data::PaneDataStore;
use crate::config::CpuConfig;
use crate::tui::app::HoveredPaneRow;
use crate::tui::running_targets::ProjectTargetSlice;
use crate::tui::running_targets::RunningTargetsPoller;
use crate::tui::startup_services::StartupServices;

/// Owns every pane-related piece of state. App holds a single `panes:
/// Panes` field.
pub struct Panes {
    pub package:      PackagePane,
    pub lang:         LangPane,
    pub cpu:          CpuPane,
    pub git:          GitPane,
    pub output:       OutputPane,
    pub targets:      TargetsPane,
    pub project_list: ProjectListPane,

    pub pane_data:       PaneDataStore,
    /// Resolved tiled-pane layout computed by the most recent render.
    /// Input dispatch (mouse hit-tests, scroll routing) reads this;
    /// render writes it once per draw.
    pub tiled_layout:    ResolvedPaneLayout<super::PaneId>,
    hovered_row:         Option<HoveredPaneRow>,
    /// Polls running OS processes and matches them against known cargo
    /// targets. Ticked once per frame from the render thread.
    pub running_targets: RunningTargetsPoller,

    /// Cached cross-project Details/Git top-row inner height, keyed on the
    /// scan generation and the two top-pane widths. The cross-project scan
    /// rebuilds every project's pane data, so it runs only when project data
    /// changes or the terminal resizes.
    top_row_height_cache: TopRowHeightCache,
}

/// One-entry memo for [`Panes::cached_top_row_height`]. The key is
/// `(scan generation, package pane width, git pane width)`.
#[derive(Default)]
struct TopRowHeightCache {
    key:   Option<(u64, u16, u16)>,
    value: u16,
}

impl Panes {
    pub fn new(cpu_cfg: &CpuConfig, startup_services: StartupServices) -> Self {
        Self {
            package:      PackagePane::new(),
            lang:         LangPane::new(),
            cpu:          CpuPane::new(cpu_cfg, startup_services.clone()),
            git:          GitPane::new(),
            output:       OutputPane::new(),
            targets:      TargetsPane::new(),
            project_list: ProjectListPane::new(),

            pane_data:            PaneDataStore::new(),
            tiled_layout:         ResolvedPaneLayout::default(),
            hovered_row:          None,
            running_targets:      RunningTargetsPoller::new(
                RUNNING_TARGETS_POLL_INTERVAL,
                startup_services,
            ),
            top_row_height_cache: TopRowHeightCache::default(),
        }
    }

    /// Cached cross-project top-row inner height for `key` =
    /// `(scan generation, package width, git width)`, or `None` when the cache
    /// holds a different key. The caller recomputes on a miss via
    /// [`super::max_top_pane_inner_height`] and stores it with
    /// [`Self::store_top_row_height`].
    pub fn cached_top_row_height(&self, key: (u64, u16, u16)) -> Option<u16> {
        (self.top_row_height_cache.key == Some(key)).then_some(self.top_row_height_cache.value)
    }

    /// Store the cross-project top-row inner height computed for `key`.
    pub const fn store_top_row_height(&mut self, key: (u64, u16, u16), value: u16) {
        self.top_row_height_cache.key = Some(key);
        self.top_row_height_cache.value = value;
    }

    /// Currently-hovered pane/row pair, or `None`. Used by the
    /// App-level `apply_hovered_pane_row` orchestrator.
    pub const fn hovered_row(&self) -> Option<HoveredPaneRow> { self.hovered_row }

    /// Write the detail-set content across the four migrated detail
    /// panes (Package/Git/CI/Lints) plus the targets slot in
    /// `PaneDataStore`, and update the detail stamp. The "all five
    /// panes coherent for this stamp" invariant is preserved by this
    /// orchestrator: callers cannot write one detail member without
    /// writing the others.
    pub fn set_detail_data(
        &mut self,
        stamp: super::data::DetailCacheKey,
        package: super::PackageData,
        git: super::GitData,
        targets: super::TargetsData,
    ) {
        self.package.set_content(package);
        self.git.set_content(git);
        self.targets.set_content(targets);
        self.pane_data.set_detail_stamp(Some(stamp));
    }

    /// Clear the detail set across the migrated detail panes owned by `Panes`,
    /// stamping with `stamp`. Mirrors `set_detail_data`'s fan-out. CI and lint
    /// content live on their own subsystems and are cleared by the caller.
    pub fn clear_detail_data(&mut self, stamp: Option<super::data::DetailCacheKey>) {
        self.package.clear_content();
        self.git.clear_content();
        self.targets.clear_content();
        self.pane_data.set_detail_stamp(stamp);
    }

    pub const fn set_hover(&mut self, hovered: Option<HoveredPaneRow>) {
        self.hovered_row = hovered;
    }

    /// Drop tree-derived caches owned by per-pane structs.
    /// Currently only `GitPane`'s worktree-summary map.
    pub fn clear_for_tree_change(&self) { self.clear_worktree_summary_cache(); }

    /// Drop `GitPane`'s cached worktree-summary rows. The cached rows embed
    /// each entry's branch name, read from in-memory `CheckoutInfo`; when a
    /// later `CheckoutInfo` lands the rows must recompute, or branches that
    /// were unresolved at first render stay blank.
    pub fn clear_worktree_summary_cache(&self) { self.git.clear_worktree_summary_cache(); }

    /// Drain the CPU pane's background sampler. Delegates to `CpuPane::tick`.
    pub fn cpu_tick(&mut self) { self.cpu.tick(); }

    /// Refresh the running-targets snapshot. Caller builds `projects`
    /// from cached `cargo metadata` results. The poller gates its own
    /// cadence — calling on every frame is cheap when not due.
    pub fn running_targets_tick(&mut self, now: Instant, projects: &[ProjectTargetSlice<'_>]) {
        self.running_targets.tick(now, projects);
    }

    /// Reset the CPU pane after a config reload changes CPU poll
    /// behavior. Delegates to `CpuPane::reset`.
    pub fn reset_cpu(&mut self, cpu_config: &CpuConfig) { self.cpu.reset(cpu_config); }

    /// Seed the CPU pane's content with the current poller's
    /// placeholder `CpuUsage`. Delegates to
    /// `CpuPane::install_placeholder`. Used from `App::finish_new`.
    pub fn install_cpu_placeholder(&mut self) { self.cpu.install_placeholder(); }
}

#[cfg(test)]
mod detail_set_tests {
    //! Pin the detail-set "all five panes coherent for this stamp"
    //! invariant on `Panes::set_detail_data` /
    //! `Panes::clear_detail_data`, which fan out across the four
    //! detail panes' content slots plus the targets slot.
    //! `PaneDataStore` itself only tracks the stamp.
    use super::Panes;
    use crate::config::CpuConfig;
    use crate::tui::app::VisibleRow;
    use crate::tui::panes::GitData;
    use crate::tui::panes::PackageData;
    use crate::tui::panes::TargetsData;
    use crate::tui::panes::data::DetailCacheKey;

    fn fresh() -> Panes {
        Panes::new(
            &CpuConfig::default(),
            crate::tui::startup_services::StartupServices::quiet_unit_test(),
        )
    }

    fn any_row() -> VisibleRow { VisibleRow::Root { node_index: 0 } }

    fn other_row() -> VisibleRow {
        VisibleRow::Member {
            node_index:   0,
            group_index:  0,
            member_index: 0,
        }
    }

    fn empty_detail() -> (PackageData, GitData, TargetsData) {
        (
            PackageData::default(),
            GitData::default(),
            TargetsData::default(),
        )
    }

    #[test]
    fn new_panes_detail_is_current_only_with_no_selection() {
        let panes = fresh();
        assert!(panes.pane_data.detail_is_current(None));
        assert!(!panes.pane_data.detail_is_current(Some(DetailCacheKey {
            visible_row: any_row(),
            generation:  0,
        })));
    }

    #[test]
    fn set_detail_data_writes_all_panes_and_stamps() {
        let mut panes = fresh();
        let key = DetailCacheKey {
            visible_row: any_row(),
            generation:  3,
        };
        let (pkg, git, targets) = empty_detail();
        panes.set_detail_data(key, pkg, git, targets);

        assert!(panes.pane_data.detail_is_current(Some(key)));
        assert!(panes.package.content().is_some());
        assert!(panes.git.content().is_some());
        assert!(panes.targets.content().is_some());

        // Different stamps don't match.
        assert!(!panes.pane_data.detail_is_current(None));
        assert!(!panes.pane_data.detail_is_current(Some(DetailCacheKey {
            visible_row: any_row(),
            generation:  4,
        })));
        assert!(!panes.pane_data.detail_is_current(Some(DetailCacheKey {
            visible_row: other_row(),
            generation:  3,
        })));
    }

    #[test]
    fn clear_detail_data_clears_all_panes_and_records_stamp() {
        let mut panes = fresh();
        let key = DetailCacheKey {
            visible_row: any_row(),
            generation:  7,
        };
        let (pkg, git, targets) = empty_detail();
        panes.set_detail_data(key, pkg, git, targets);

        let clear_key = DetailCacheKey {
            visible_row: other_row(),
            generation:  7,
        };
        panes.clear_detail_data(Some(clear_key));
        assert!(panes.pane_data.detail_is_current(Some(clear_key)));
        assert!(panes.package.content().is_none());
        assert!(panes.git.content().is_none());
        assert!(panes.targets.content().is_none());
    }

    #[test]
    fn clear_detail_with_none_matches_none() {
        let mut panes = fresh();
        panes.clear_detail_data(None);
        assert!(panes.pane_data.detail_is_current(None));
    }
}