Skip to main content

purple_ssh/app/
containers_overview.rs

1//! State for the global Containers tab (top_page = Containers).
2
3use std::collections::{HashMap, HashSet, VecDeque};
4
5use super::host_state::ViewMode;
6use crate::containers::{ContainerInspect, ContainerRuntime};
7
8/// One queued host in a `R` batch refresh: everything the listing
9/// thread needs to spawn an SSH `docker ps` for that alias.
10#[derive(Debug, Clone)]
11pub struct RefreshQueueItem {
12    pub alias: String,
13    pub askpass: Option<String>,
14    /// Password typed in the TUI for this host this session, resolved when
15    /// the item was queued so the worker needs no App access.
16    pub session_password: Option<String>,
17    pub cached_runtime: Option<ContainerRuntime>,
18    pub has_tunnel: bool,
19}
20
21/// State of a `R` batch refresh. None when no batch is active.
22/// Drives a windowed concurrency: at most `MAX_PARALLEL` listings are
23/// in flight at any time. Each `ContainerListing` event decrements
24/// `in_flight` and pops the next queued item; the batch ends when
25/// `queue` is empty and `in_flight` drops to zero.
26#[derive(Debug, Default)]
27pub struct RefreshBatch {
28    pub queue: VecDeque<RefreshQueueItem>,
29    pub in_flight: usize,
30    /// Total host count when the batch started. used for the
31    /// `Refreshing N/M` progress readout in the status footer.
32    pub total: usize,
33    /// Hosts whose listing has already returned (success or error).
34    pub completed: usize,
35    /// Aliases that the batch has spawned but not yet seen complete.
36    /// Listings whose alias is NOT in this set are non-batch traffic
37    /// (host-list `C`, action-complete refresh, `a`-add) and must not
38    /// touch the counters. Without this guard the in-flight counter
39    /// gets corrupted whenever a parallel non-batch fetch completes
40    /// during a `R` run.
41    pub in_flight_aliases: HashSet<String>,
42}
43
44/// Cap on parallel SSH connections during a `R` batch refresh. Picked
45/// to keep load on the local SSH agent and remote sshd reasonable
46/// while still amortising connection setup.
47pub const REFRESH_MAX_PARALLEL: usize = 4;
48
49/// Pending request to drop the user into a remote container shell. Set
50/// by the handler when Enter is pressed on a running container; drained
51/// by `handle_pending_container_exec` in the main loop, which suspends
52/// the TUI, runs `ssh -t <alias> <runtime> exec -it <id> sh -c
53/// 'bash || sh'`, then restores the TUI on exit.
54///
55/// `command` is the full remote command. `None` runs the default
56/// shell (`sh -c 'bash || sh'`); `Some` runs the user-typed exec
57/// prompt verbatim (validated upstream. must not contain newlines).
58#[derive(Debug, Clone)]
59pub struct ContainerExecRequest {
60    pub alias: String,
61    pub askpass: Option<String>,
62    pub runtime: ContainerRuntime,
63    pub container_id: String,
64    /// Human-readable container name (for the log line and the toast
65    /// the user sees when the session ends). Not used to build the
66    /// command. the validated `container_id` is what addresses the
67    /// container.
68    pub container_name: String,
69    /// User-supplied command. `None` falls back to the interactive
70    /// shell (`sh -c 'bash || sh'`) used by the default Enter binding.
71    /// `Some` is the verbatim payload from the `e` exec prompt.
72    pub command: Option<String>,
73}
74
75/// Pending request to fetch container logs from a remote host. Drained
76/// by the main loop into a background SSH thread; the result returns
77/// via `AppEvent::ContainerLogsComplete` and lands on the
78/// `Screen::ContainerLogs` overlay the user opened with `l`.
79#[derive(Debug, Clone)]
80pub struct ContainerLogsRequest {
81    pub alias: String,
82    pub askpass: Option<String>,
83    pub runtime: ContainerRuntime,
84    pub container_id: String,
85    pub container_name: String,
86}
87
88/// Pending non-interactive container action (restart, stop). Same shape
89/// as `ContainerExecRequest` but plus an action tag and minus the askpass
90/// handling for an interactive shell. these run in a worker thread, not
91/// the foreground TUI. Reuses `containers::ContainerAction` so the
92/// command formatter (`container_action_command`) is shared.
93#[derive(Debug, Clone)]
94pub struct ContainerActionRequest {
95    pub alias: String,
96    pub askpass: Option<String>,
97    pub runtime: ContainerRuntime,
98    pub container_id: String,
99    pub container_name: String,
100    pub action: crate::containers::ContainerAction,
101}
102
103/// Sort order for the containers overview screen. Cycled with `s`.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105pub enum ContainersSortMode {
106    /// Alphabetical by host alias, then container name.
107    #[default]
108    AlphaHost,
109    /// Alphabetical by container name, then host alias.
110    AlphaContainer,
111}
112
113impl ContainersSortMode {
114    pub fn next(self) -> Self {
115        match self {
116            ContainersSortMode::AlphaHost => ContainersSortMode::AlphaContainer,
117            ContainersSortMode::AlphaContainer => ContainersSortMode::AlphaHost,
118        }
119    }
120
121    pub fn label(self) -> &'static str {
122        match self {
123            ContainersSortMode::AlphaHost => "A-Z host",
124            ContainersSortMode::AlphaContainer => "A-Z container",
125        }
126    }
127
128    pub fn to_key(self) -> &'static str {
129        match self {
130            ContainersSortMode::AlphaHost => "alpha_host",
131            ContainersSortMode::AlphaContainer => "alpha_container",
132        }
133    }
134
135    pub fn from_key(s: &str) -> Self {
136        match s {
137            "alpha_container" => ContainersSortMode::AlphaContainer,
138            _ => ContainersSortMode::AlphaHost,
139        }
140    }
141}
142
143/// One cached `docker inspect` result, paired with the wall-clock seconds
144/// at which it was fetched. The detail panel treats entries older than
145/// `INSPECT_CACHE_TTL_SECS` as stale and re-fires the SSH call.
146#[derive(Debug, Clone)]
147pub struct InspectCacheEntry {
148    pub timestamp: u64,
149    pub result: Result<ContainerInspect, String>,
150}
151
152/// Detail-panel inspect cache and in-flight tracking. Keyed on the full
153/// container ID rather than `(alias, container_id)` because `docker`
154/// container IDs are globally unique 64-char hex strings. collisions
155/// across hosts are practically impossible. Keeps the key shape simple.
156#[derive(Debug, Default)]
157pub struct InspectCache {
158    pub entries: HashMap<String, InspectCacheEntry>,
159    /// Container IDs with a pending background `inspect` thread. Prevents
160    /// re-firing while a previous fetch is still in flight.
161    pub in_flight: HashSet<String>,
162}
163
164/// One cached `docker logs --tail N` result. Same TTL semantics as
165/// `InspectCacheEntry`: the LOGS card on the detail panel re-fires the
166/// SSH call once the entry is older than `LOGS_CACHE_TTL_SECS`.
167#[derive(Debug, Clone)]
168pub struct LogsCacheEntry {
169    pub timestamp: u64,
170    pub result: Result<Vec<String>, String>,
171}
172
173/// Detail-panel logs cache and in-flight tracking. Mirrors `InspectCache`
174/// so the LOGS card and the inspect cards refresh on the same rhythm.
175#[derive(Debug, Default)]
176pub struct LogsCache {
177    pub entries: HashMap<String, LogsCacheEntry>,
178    pub in_flight: HashSet<String>,
179}
180
181/// Cache TTL in seconds. Kept short so resource-y fields like
182/// `RestartCount` and `Health.Status` do not lag too far behind reality
183/// while still avoiding an SSH storm when the user scrolls a long list.
184pub const INSPECT_CACHE_TTL_SECS: u64 = 30;
185
186/// TTL for the per-container `docker logs --tail` cache feeding the
187/// LOGS card. Same value as the inspect cache so a single user-driven
188/// refresh re-fires both streams in lockstep.
189pub const LOGS_CACHE_TTL_SECS: u64 = 30;
190
191/// How many log lines the LOGS card requests via `--tail`. Sized for
192/// the worst-case panel height we expect (a tall terminal can fit
193/// dozens of lines inside the card); the renderer slices the trailing
194/// `inner_capacity` rows so a short panel only paints what fits.
195pub const LOGS_TAIL: usize = 50;
196
197/// TTL for the per-host `docker ps` cache used by the auto-list-refresh
198/// helper. When the user scrolls to a row whose host has a stale (or
199/// missing) entry in `container_cache`, we re-fire the listing so the
200/// running/exited counts and uptime in the visible row reflect reality.
201/// Same value as the inspect TTL so the two refresh streams stay
202/// loosely in lockstep.
203pub const LIST_CACHE_TTL_SECS: u64 = 30;
204
205/// Which bulk-action confirm dialog is open. Mirrored by the
206/// corresponding `Screen::Confirm*` variant (now data-less) so the
207/// renderer knows which header to draw.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum BulkConfirmKind {
210    /// `Ctrl-K` on a container row: restart every running member of
211    /// the selected container's compose stack.
212    StackRestart,
213    /// `K` on a host-divider row: restart every running container on
214    /// that host, ignoring compose-project boundaries.
215    HostRestartAll,
216    /// `S` on a host-divider row: stop every running container on
217    /// that host.
218    HostStopAll,
219}
220
221/// Payload of the three bulk container confirm dialogs. The Screen
222/// variant carries only the kind tag; the alias/project/members live
223/// here so screen transitions never clone the member vec.
224#[derive(Debug, Clone)]
225pub struct BulkConfirmContext {
226    pub kind: BulkConfirmKind,
227    pub alias: String,
228    /// Compose project name. Always `Some` for `StackRestart`, `None`
229    /// for the host-wide variants.
230    pub project: Option<String>,
231    pub members: Vec<crate::app::StackMember>,
232}
233
234#[derive(Debug)]
235pub struct ContainersOverviewState {
236    pub(in crate::app) sort_mode: ContainersSortMode,
237    pub(in crate::app) inspect_cache: InspectCache,
238    pub(in crate::app) logs_cache: LogsCache,
239    /// Currently-running `R` batch, if any. `None` when idle.
240    pub(in crate::app) refresh_batch: Option<RefreshBatch>,
241    /// Aliases whose `docker ps` listing was kicked off by the
242    /// scroll-driven auto-refresh helper and has not yet returned.
243    /// Lets the helper skip a re-spawn while one is already pending.
244    /// Cleared by `handle_container_listing` on arrival.
245    pub(in crate::app) auto_list_in_flight: HashSet<String>,
246    /// Toggle for the per-row detail panel on the right. Mirrors the
247    /// host-list `v` toggle. Default `Detailed` so the panel is visible
248    /// whenever the terminal is wide enough.
249    pub(in crate::app) view_mode: ViewMode,
250    /// Aliases whose container group is currently collapsed in the
251    /// AlphaHost rendering. Persisted across sessions via preferences
252    /// so a folded group stays folded after restart.
253    pub(in crate::app) collapsed_hosts: HashSet<String>,
254    /// Memoized render list. The render and handler paths call
255    /// `visible_items` repeatedly (24 call sites, several per key
256    /// event) and each call cloned 6 String fields per container. The
257    /// cache stores the built `Vec<ContainerListItem>` keyed on a
258    /// content fingerprint over the inputs (sort_mode, search query,
259    /// collapsed_hosts, per-host (timestamp, container_count)). On a
260    /// hit we skip the collect/sort/intersperse step entirely and
261    /// return a clone of the cached vec. The fingerprint walks all
262    /// hosts but only reads a few fields each, so it is dramatically
263    /// cheaper than rebuilding the row set.
264    pub(in crate::app) view_cache:
265        std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>,
266    /// Payload of the currently-open bulk container confirm dialog
267    /// (`ConfirmStackRestart` / `ConfirmHostRestartAll` /
268    /// `ConfirmHostStopAll`). `None` when no such dialog is open.
269    pub(in crate::app) pending_bulk_confirm: Option<BulkConfirmContext>,
270}
271
272impl Default for ContainersOverviewState {
273    fn default() -> Self {
274        Self {
275            sort_mode: ContainersSortMode::default(),
276            inspect_cache: InspectCache::default(),
277            logs_cache: LogsCache::default(),
278            refresh_batch: None,
279            auto_list_in_flight: HashSet::new(),
280            view_mode: ViewMode::Detailed,
281            collapsed_hosts: HashSet::new(),
282            view_cache: std::cell::RefCell::new(None),
283            pending_bulk_confirm: None,
284        }
285    }
286}
287
288impl ContainersOverviewState {
289    /// Install a fresh refresh batch. Caller is responsible for spawning
290    /// the initial parallel listings after this call; this method only
291    /// owns the state slot.
292    pub fn start_refresh(&mut self, batch: RefreshBatch) {
293        self.refresh_batch = Some(batch);
294    }
295
296    /// Drop the active refresh batch. Called when the queue drains and
297    /// in-flight count returns to zero.
298    pub fn clear_refresh(&mut self) {
299        self.refresh_batch = None;
300    }
301
302    // Sealed-field accessors. Fields are `pub(in crate::app)`; callers
303    // outside the app module reach state through these.
304
305    pub fn sort_mode(&self) -> ContainersSortMode {
306        self.sort_mode
307    }
308
309    /// Set the sort mode without persisting to preferences. Demo mode and
310    /// tests only; the persisting path is `set_sort_mode`.
311    pub fn set_sort_mode_ephemeral(&mut self, mode: ContainersSortMode) {
312        self.sort_mode = mode;
313    }
314
315    pub fn view_mode(&self) -> ViewMode {
316        self.view_mode
317    }
318
319    /// Set the view mode without persisting to preferences. Demo mode and
320    /// tests only; the persisting path is `set_view_mode`.
321    pub fn set_view_mode_ephemeral(&mut self, mode: ViewMode) {
322        self.view_mode = mode;
323    }
324
325    pub fn collapsed_hosts(&self) -> &HashSet<String> {
326        &self.collapsed_hosts
327    }
328
329    /// Read the active bulk confirm payload (`None` when no bulk
330    /// container confirm dialog is open).
331    pub fn pending_bulk_confirm(&self) -> Option<&BulkConfirmContext> {
332        self.pending_bulk_confirm.as_ref()
333    }
334
335    /// Install a fresh bulk confirm payload. Caller is responsible for
336    /// transitioning the screen to the matching `Screen::Confirm*`
337    /// variant.
338    pub fn set_pending_bulk_confirm(&mut self, ctx: BulkConfirmContext) {
339        self.pending_bulk_confirm = Some(ctx);
340    }
341
342    /// Drop the active bulk confirm payload, returning it for use by
343    /// the confirm-yes handler.
344    pub fn take_pending_bulk_confirm(&mut self) -> Option<BulkConfirmContext> {
345        self.pending_bulk_confirm.take()
346    }
347
348    /// Fold or unfold a host group; returns the new collapsed state.
349    pub fn toggle_host_collapsed(&mut self, alias: &str) -> bool {
350        if self.collapsed_hosts.remove(alias) {
351            false
352        } else {
353            self.collapsed_hosts.insert(alias.to_string());
354            true
355        }
356    }
357
358    pub fn refresh_batch(&self) -> Option<&RefreshBatch> {
359        self.refresh_batch.as_ref()
360    }
361
362    pub fn refresh_batch_mut(&mut self) -> Option<&mut RefreshBatch> {
363        self.refresh_batch.as_mut()
364    }
365
366    pub fn auto_list_in_flight(&self) -> &HashSet<String> {
367        &self.auto_list_in_flight
368    }
369
370    /// True when a scroll-driven auto-listing is already in flight for
371    /// `alias`, so the helper skips a re-spawn.
372    pub fn auto_list_pending(&self, alias: &str) -> bool {
373        self.auto_list_in_flight.contains(alias)
374    }
375
376    /// Mark a scroll-driven auto-listing as in flight for `alias`.
377    pub fn mark_auto_list_pending(&mut self, alias: String) {
378        self.auto_list_in_flight.insert(alias);
379    }
380
381    /// Clear the in-flight marker once an auto-listing arrives.
382    pub fn clear_auto_list_pending(&mut self, alias: &str) {
383        self.auto_list_in_flight.remove(alias);
384    }
385
386    pub fn inspect_cache(&self) -> &InspectCache {
387        &self.inspect_cache
388    }
389
390    pub fn inspect_cache_mut(&mut self) -> &mut InspectCache {
391        &mut self.inspect_cache
392    }
393
394    pub fn logs_cache(&self) -> &LogsCache {
395        &self.logs_cache
396    }
397
398    pub fn logs_cache_mut(&mut self) -> &mut LogsCache {
399        &mut self.logs_cache
400    }
401
402    pub fn view_cache(
403        &self,
404    ) -> &std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>
405    {
406        &self.view_cache
407    }
408
409    /// Load the persisted overview fields from `preferences`. Startup only:
410    /// clobbers any in-memory state with whatever is on disk. `pub(crate)`
411    /// so a stray mid-session caller cannot quietly revert user edits.
412    pub(crate) fn hydrate_from_prefs(&mut self, paths: Option<&crate::runtime::env::Paths>) {
413        self.view_mode = crate::preferences::load_containers_view_mode(paths);
414        self.sort_mode = crate::preferences::load_containers_sort_mode(paths);
415        self.collapsed_hosts = crate::preferences::load_containers_collapsed_hosts(paths);
416    }
417
418    /// Update `view_mode` and persist. Returns the persist error so the
419    /// caller can surface it (current call site discards it intentionally
420    /// to match the pre-encapsulation behavior where view-mode persist
421    /// failures only logged).
422    pub fn set_view_mode(
423        &mut self,
424        paths: Option<&crate::runtime::env::Paths>,
425        mode: ViewMode,
426    ) -> std::io::Result<()> {
427        self.view_mode = mode;
428        crate::preferences::save_containers_view_mode(paths, mode).inspect_err(|e| {
429            log::warn!("[config] Failed to persist containers view mode: {e}");
430        })
431    }
432
433    /// Update `sort_mode` and persist. Same contract as `set_view_mode`;
434    /// the call site does surface the error via a toast.
435    pub fn set_sort_mode(
436        &mut self,
437        paths: Option<&crate::runtime::env::Paths>,
438        mode: ContainersSortMode,
439    ) -> std::io::Result<()> {
440        self.sort_mode = mode;
441        crate::preferences::save_containers_sort_mode(paths, mode).inspect_err(|e| {
442            log::warn!("[config] Failed to persist containers sort mode: {e}");
443        })
444    }
445
446    /// Rename an alias across every alias-keyed set in this state.
447    /// Returns `true` when `collapsed_hosts` changed so the caller can
448    /// persist; `auto_list_in_flight` and `refresh_batch.in_flight_aliases`
449    /// are also migrated but are not persistent so they do not affect
450    /// the return value. No-op (returns `false`) when `old == new`.
451    pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
452        if old == new {
453            return false;
454        }
455        if self.auto_list_in_flight.remove(old) {
456            debug_assert!(
457                !self.auto_list_in_flight.contains(new),
458                "auto_list_in_flight collision on rename {old} -> {new}"
459            );
460            self.auto_list_in_flight.insert(new.to_string());
461        }
462        if let Some(batch) = self.refresh_batch.as_mut()
463            && batch.in_flight_aliases.remove(old)
464        {
465            debug_assert!(
466                !batch.in_flight_aliases.contains(new),
467                "refresh_batch.in_flight_aliases collision on rename {old} -> {new}"
468            );
469            batch.in_flight_aliases.insert(new.to_string());
470        }
471        if let Some(ctx) = self.pending_bulk_confirm.as_mut()
472            && ctx.alias == old
473        {
474            ctx.alias = new.to_string();
475        }
476        if self.collapsed_hosts.remove(old) {
477            debug_assert!(
478                !self.collapsed_hosts.contains(new),
479                "collapsed_hosts collision on rename {old} -> {new}"
480            );
481            self.collapsed_hosts.insert(new.to_string());
482            true
483        } else {
484            false
485        }
486    }
487
488    /// Drop inspect-cache and logs-cache entries whose container ID is
489    /// not in `valid_container_ids`. Called from `App::reload_hosts`
490    /// after the per-host container cache has been pruned: container
491    /// IDs come from the just-pruned cache, so this keeps inspect/logs
492    /// aligned with the surviving hosts.
493    pub fn prune_by_container_ids(&mut self, valid_container_ids: &HashSet<String>) {
494        let pre_inspect = self.inspect_cache.entries.len();
495        self.inspect_cache
496            .entries
497            .retain(|id, _| valid_container_ids.contains(id));
498        self.inspect_cache
499            .in_flight
500            .retain(|id| valid_container_ids.contains(id));
501        self.logs_cache
502            .entries
503            .retain(|id, _| valid_container_ids.contains(id));
504        self.logs_cache
505            .in_flight
506            .retain(|id| valid_container_ids.contains(id));
507        let dropped = pre_inspect.saturating_sub(self.inspect_cache.entries.len());
508        if dropped > 0 {
509            log::debug!("[purple] reload_hosts: dropped {dropped} orphan inspect_cache entrie(s)");
510        }
511    }
512
513    /// Drop per-alias overview state (auto-list in-flight, refresh batch
514    /// in-flight aliases, collapsed-hosts) whose alias is no longer in
515    /// `valid_aliases`. Returns `true` when `collapsed_hosts` shrank, so
516    /// the caller can persist the trimmed set via
517    /// `preferences::save_containers_collapsed_hosts`.
518    pub fn prune_orphans(&mut self, valid_aliases: &HashSet<&str>) -> bool {
519        // Auto-list in-flight markers for deleted hosts. The listing
520        // thread still posts a result and the race guard in
521        // `handle_container_listing` removes it; pruning here keeps
522        // debug state clean and avoids a false-positive dedup hit if
523        // the same alias is re-added before the stray listing returns.
524        self.auto_list_in_flight
525            .retain(|alias| valid_aliases.contains(alias.as_str()));
526
527        // Container-overview refresh batch (R). Tracks in-flight aliases
528        // to gate counter updates against non-batch listings; prune so a
529        // host removed mid-batch cannot linger.
530        if let Some(batch) = self.refresh_batch.as_mut() {
531            let pre = batch.in_flight_aliases.len();
532            batch
533                .in_flight_aliases
534                .retain(|alias| valid_aliases.contains(alias.as_str()));
535            let dropped = pre.saturating_sub(batch.in_flight_aliases.len());
536            if dropped > 0 {
537                log::debug!(
538                    "[purple] reload_hosts: dropped {dropped} orphan refresh_batch in_flight alias(es)"
539                );
540            }
541        }
542
543        // Bulk-confirm payload aimed at a deleted host: clear it; the
544        // matching Screen::Confirm* variant would render an empty body
545        // and a y press would no-op.
546        if let Some(ctx) = self.pending_bulk_confirm.as_ref()
547            && !valid_aliases.contains(ctx.alias.as_str())
548        {
549            self.pending_bulk_confirm = None;
550        }
551
552        // Containers-overview collapsed groups are persisted to disk via
553        // preferences, so leftover aliases survive restart. Rename is
554        // already handled by `apply_alias_renames`; this covers delete.
555        let pre_collapsed = self.collapsed_hosts.len();
556        self.collapsed_hosts
557            .retain(|alias| valid_aliases.contains(alias.as_str()));
558        let dropped_collapsed = pre_collapsed.saturating_sub(self.collapsed_hosts.len());
559        if dropped_collapsed > 0 {
560            log::debug!(
561                "[purple] reload_hosts: dropped {dropped_collapsed} orphan collapsed_hosts entrie(s)"
562            );
563            true
564        } else {
565            false
566        }
567    }
568}
569
570impl InspectCache {
571    /// Returns `Some(entry)` only when the cache holds a *fresh* entry
572    /// for `container_id` (`now - timestamp < TTL`). Stale entries
573    /// behave like absent ones so the trigger code re-fetches.
574    pub fn fresh(&self, container_id: &str, now: u64) -> Option<&InspectCacheEntry> {
575        self.entries
576            .get(container_id)
577            .filter(|e| now.saturating_sub(e.timestamp) < INSPECT_CACHE_TTL_SECS)
578    }
579}
580
581impl LogsCache {
582    /// Same fresh-window contract as `InspectCache::fresh`, against
583    /// `LOGS_CACHE_TTL_SECS`.
584    pub fn fresh(&self, container_id: &str, now: u64) -> Option<&LogsCacheEntry> {
585        self.entries
586            .get(container_id)
587            .filter(|e| now.saturating_sub(e.timestamp) < LOGS_CACHE_TTL_SECS)
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use crate::runtime::env::Paths;
595
596    fn batch_with_aliases(aliases: &[&str]) -> RefreshBatch {
597        RefreshBatch {
598            queue: VecDeque::new(),
599            in_flight: aliases.len(),
600            total: aliases.len(),
601            completed: 0,
602            in_flight_aliases: aliases.iter().map(|a| a.to_string()).collect(),
603        }
604    }
605
606    #[test]
607    fn start_refresh_installs_batch() {
608        let mut state = ContainersOverviewState::default();
609        assert!(state.refresh_batch.is_none());
610        state.start_refresh(batch_with_aliases(&["host-a", "host-b"]));
611        let batch = state.refresh_batch.as_ref().unwrap();
612        assert_eq!(batch.total, 2);
613        assert_eq!(batch.in_flight, 2);
614        assert!(batch.in_flight_aliases.contains("host-a"));
615    }
616
617    #[test]
618    fn clear_refresh_drops_batch() {
619        let mut state = ContainersOverviewState::default();
620        state.start_refresh(batch_with_aliases(&["host-a"]));
621        state.clear_refresh();
622        assert!(state.refresh_batch.is_none());
623    }
624
625    #[test]
626    fn hydrate_from_prefs_reads_persisted_values() {
627        let dir = tempfile::tempdir().unwrap();
628        let paths = Paths::new(dir.path());
629        crate::preferences::save_containers_view_mode(Some(&paths), ViewMode::Compact).unwrap();
630        crate::preferences::save_containers_sort_mode(
631            Some(&paths),
632            ContainersSortMode::AlphaContainer,
633        )
634        .unwrap();
635        let mut collapsed = std::collections::HashSet::new();
636        collapsed.insert("folded-host".to_string());
637        crate::preferences::save_containers_collapsed_hosts(Some(&paths), &collapsed).unwrap();
638
639        let mut state = ContainersOverviewState::default();
640        state.hydrate_from_prefs(Some(&paths));
641        assert_eq!(state.view_mode, ViewMode::Compact);
642        assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
643        assert!(state.collapsed_hosts.contains("folded-host"));
644    }
645
646    #[test]
647    fn set_view_mode_updates_field_and_persists() {
648        let dir = tempfile::tempdir().unwrap();
649        let paths = Paths::new(dir.path());
650        let mut state = ContainersOverviewState::default();
651        state
652            .set_view_mode(Some(&paths), ViewMode::Compact)
653            .unwrap();
654        assert_eq!(state.view_mode, ViewMode::Compact);
655        assert_eq!(
656            crate::preferences::load_containers_view_mode(Some(&paths)),
657            ViewMode::Compact
658        );
659    }
660
661    #[test]
662    fn set_sort_mode_updates_field_and_persists() {
663        let dir = tempfile::tempdir().unwrap();
664        let paths = Paths::new(dir.path());
665        let mut state = ContainersOverviewState::default();
666        state
667            .set_sort_mode(Some(&paths), ContainersSortMode::AlphaContainer)
668            .unwrap();
669        assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
670        assert_eq!(
671            crate::preferences::load_containers_sort_mode(Some(&paths)),
672            ContainersSortMode::AlphaContainer
673        );
674    }
675
676    #[test]
677    fn migrate_alias_renames_auto_list_in_flight() {
678        let mut state = ContainersOverviewState::default();
679        state.auto_list_in_flight.insert("old".to_string());
680        state.migrate_alias("old", "new");
681        assert!(state.auto_list_in_flight.contains("new"));
682        assert!(!state.auto_list_in_flight.contains("old"));
683    }
684
685    #[test]
686    fn migrate_alias_renames_refresh_batch_in_flight() {
687        let mut state = ContainersOverviewState::default();
688        state.start_refresh(batch_with_aliases(&["old"]));
689        // Non-persistent change: collapsed_hosts is untouched so the
690        // return value must be false even though the in-flight set
691        // was migrated. Pins the contract that drives the persist
692        // call in app::hosts::migrate_alias_keyed_caches.
693        assert!(!state.migrate_alias("old", "new"));
694        let batch = state.refresh_batch.as_ref().unwrap();
695        assert!(batch.in_flight_aliases.contains("new"));
696        assert!(!batch.in_flight_aliases.contains("old"));
697    }
698
699    #[test]
700    fn migrate_alias_self_rename_is_noop() {
701        let mut state = ContainersOverviewState::default();
702        state.collapsed_hosts.insert("same".to_string());
703        state.auto_list_in_flight.insert("same".to_string());
704        assert!(!state.migrate_alias("same", "same"));
705        assert!(state.collapsed_hosts.contains("same"));
706        assert!(state.auto_list_in_flight.contains("same"));
707    }
708
709    #[test]
710    fn migrate_alias_renames_collapsed_hosts_and_returns_true() {
711        let mut state = ContainersOverviewState::default();
712        state.collapsed_hosts.insert("old".to_string());
713        assert!(state.migrate_alias("old", "new"));
714        assert!(state.collapsed_hosts.contains("new"));
715        assert!(!state.collapsed_hosts.contains("old"));
716    }
717
718    #[test]
719    fn migrate_alias_returns_false_when_collapsed_unchanged() {
720        let mut state = ContainersOverviewState::default();
721        state.auto_list_in_flight.insert("old".to_string());
722        assert!(!state.migrate_alias("old", "new"));
723        assert!(state.auto_list_in_flight.contains("new"));
724    }
725
726    #[test]
727    fn migrate_alias_is_noop_when_nothing_matches() {
728        let mut state = ContainersOverviewState::default();
729        assert!(!state.migrate_alias("missing", "new"));
730    }
731
732    #[test]
733    fn prune_by_container_ids_drops_unknown_id_from_in_flight_sets() {
734        let mut state = ContainersOverviewState::default();
735        state.inspect_cache.in_flight.insert("id-keep".to_string());
736        state.inspect_cache.in_flight.insert("id-drop".to_string());
737        state.logs_cache.in_flight.insert("id-keep".to_string());
738        state.logs_cache.in_flight.insert("id-drop".to_string());
739
740        let valid: HashSet<String> = ["id-keep".to_string()].into_iter().collect();
741        state.prune_by_container_ids(&valid);
742
743        assert!(state.inspect_cache.in_flight.contains("id-keep"));
744        assert!(!state.inspect_cache.in_flight.contains("id-drop"));
745        assert!(state.logs_cache.in_flight.contains("id-keep"));
746        assert!(!state.logs_cache.in_flight.contains("id-drop"));
747    }
748
749    #[test]
750    fn prune_by_container_ids_drops_unknown_id_from_entries_maps() {
751        // Distinct from the in_flight test: this one populates the
752        // `.entries` HashMaps so the retain() calls covering them
753        // cannot be removed without a test failure.
754        let mut state = ContainersOverviewState::default();
755        state.inspect_cache.entries.insert(
756            "id-keep".to_string(),
757            InspectCacheEntry {
758                timestamp: 0,
759                result: Err("placeholder".to_string()),
760            },
761        );
762        state.inspect_cache.entries.insert(
763            "id-drop".to_string(),
764            InspectCacheEntry {
765                timestamp: 0,
766                result: Err("placeholder".to_string()),
767            },
768        );
769        state.logs_cache.entries.insert(
770            "id-keep".to_string(),
771            LogsCacheEntry {
772                timestamp: 0,
773                result: Ok(vec!["line".to_string()]),
774            },
775        );
776        state.logs_cache.entries.insert(
777            "id-drop".to_string(),
778            LogsCacheEntry {
779                timestamp: 0,
780                result: Ok(vec!["line".to_string()]),
781            },
782        );
783
784        let valid: HashSet<String> = ["id-keep".to_string()].into_iter().collect();
785        state.prune_by_container_ids(&valid);
786
787        assert!(state.inspect_cache.entries.contains_key("id-keep"));
788        assert!(!state.inspect_cache.entries.contains_key("id-drop"));
789        assert!(state.logs_cache.entries.contains_key("id-keep"));
790        assert!(!state.logs_cache.entries.contains_key("id-drop"));
791    }
792
793    #[test]
794    fn prune_orphans_drops_unknown_and_signals_collapsed_change() {
795        let mut state = ContainersOverviewState::default();
796        state.auto_list_in_flight.insert("keep".to_string());
797        state.auto_list_in_flight.insert("drop".to_string());
798        state.collapsed_hosts.insert("keep".to_string());
799        state.collapsed_hosts.insert("drop".to_string());
800
801        let valid: HashSet<&str> = ["keep"].into_iter().collect();
802        let collapsed_changed = state.prune_orphans(&valid);
803
804        assert!(
805            collapsed_changed,
806            "returns true so caller persists collapsed_hosts"
807        );
808        assert!(state.auto_list_in_flight.contains("keep"));
809        assert!(!state.auto_list_in_flight.contains("drop"));
810        assert!(state.collapsed_hosts.contains("keep"));
811        assert!(!state.collapsed_hosts.contains("drop"));
812    }
813
814    #[test]
815    fn prune_orphans_returns_false_when_collapsed_unchanged() {
816        let mut state = ContainersOverviewState::default();
817        state.auto_list_in_flight.insert("only".to_string());
818        let valid: HashSet<&str> = ["only"].into_iter().collect();
819        assert!(!state.prune_orphans(&valid));
820    }
821}