1use std::collections::{HashMap, HashSet, VecDeque};
4
5use super::host_state::ViewMode;
6use crate::containers::{ContainerInspect, ContainerRuntime};
7
8#[derive(Debug, Clone)]
11pub struct RefreshQueueItem {
12 pub alias: String,
13 pub askpass: Option<String>,
14 pub cached_runtime: Option<ContainerRuntime>,
15 pub has_tunnel: bool,
16}
17
18#[derive(Debug, Default)]
24pub struct RefreshBatch {
25 pub queue: VecDeque<RefreshQueueItem>,
26 pub in_flight: usize,
27 pub total: usize,
30 pub completed: usize,
32 pub in_flight_aliases: HashSet<String>,
39}
40
41pub const REFRESH_MAX_PARALLEL: usize = 4;
45
46#[derive(Debug, Clone)]
56pub struct ContainerExecRequest {
57 pub alias: String,
58 pub askpass: Option<String>,
59 pub runtime: ContainerRuntime,
60 pub container_id: String,
61 pub container_name: String,
66 pub command: Option<String>,
70}
71
72#[derive(Debug, Clone)]
77pub struct ContainerLogsRequest {
78 pub alias: String,
79 pub askpass: Option<String>,
80 pub runtime: ContainerRuntime,
81 pub container_id: String,
82 pub container_name: String,
83}
84
85#[derive(Debug, Clone)]
91pub struct ContainerActionRequest {
92 pub alias: String,
93 pub askpass: Option<String>,
94 pub runtime: ContainerRuntime,
95 pub container_id: String,
96 pub container_name: String,
97 pub action: crate::containers::ContainerAction,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
102pub enum ContainersSortMode {
103 #[default]
105 AlphaHost,
106 AlphaContainer,
108}
109
110impl ContainersSortMode {
111 pub fn next(self) -> Self {
112 match self {
113 ContainersSortMode::AlphaHost => ContainersSortMode::AlphaContainer,
114 ContainersSortMode::AlphaContainer => ContainersSortMode::AlphaHost,
115 }
116 }
117
118 pub fn label(self) -> &'static str {
119 match self {
120 ContainersSortMode::AlphaHost => "A-Z host",
121 ContainersSortMode::AlphaContainer => "A-Z container",
122 }
123 }
124
125 pub fn to_key(self) -> &'static str {
126 match self {
127 ContainersSortMode::AlphaHost => "alpha_host",
128 ContainersSortMode::AlphaContainer => "alpha_container",
129 }
130 }
131
132 pub fn from_key(s: &str) -> Self {
133 match s {
134 "alpha_container" => ContainersSortMode::AlphaContainer,
135 _ => ContainersSortMode::AlphaHost,
136 }
137 }
138}
139
140#[derive(Debug, Clone)]
144pub struct InspectCacheEntry {
145 pub timestamp: u64,
146 pub result: Result<ContainerInspect, String>,
147}
148
149#[derive(Debug, Default)]
154pub struct InspectCache {
155 pub entries: HashMap<String, InspectCacheEntry>,
156 pub in_flight: HashSet<String>,
159}
160
161#[derive(Debug, Clone)]
165pub struct LogsCacheEntry {
166 pub timestamp: u64,
167 pub result: Result<Vec<String>, String>,
168}
169
170#[derive(Debug, Default)]
173pub struct LogsCache {
174 pub entries: HashMap<String, LogsCacheEntry>,
175 pub in_flight: HashSet<String>,
176}
177
178pub const INSPECT_CACHE_TTL_SECS: u64 = 30;
182
183pub const LOGS_CACHE_TTL_SECS: u64 = 30;
187
188pub const LOGS_TAIL: usize = 50;
193
194pub const LIST_CACHE_TTL_SECS: u64 = 30;
201
202#[derive(Debug)]
203pub struct ContainersOverviewState {
204 pub(in crate::app) sort_mode: ContainersSortMode,
205 pub(in crate::app) inspect_cache: InspectCache,
206 pub(in crate::app) logs_cache: LogsCache,
207 pub(in crate::app) refresh_batch: Option<RefreshBatch>,
209 pub(in crate::app) auto_list_in_flight: HashSet<String>,
214 pub(in crate::app) view_mode: ViewMode,
218 pub(in crate::app) collapsed_hosts: HashSet<String>,
222 pub(in crate::app) view_cache:
233 std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>,
234}
235
236impl Default for ContainersOverviewState {
237 fn default() -> Self {
238 Self {
239 sort_mode: ContainersSortMode::default(),
240 inspect_cache: InspectCache::default(),
241 logs_cache: LogsCache::default(),
242 refresh_batch: None,
243 auto_list_in_flight: HashSet::new(),
244 view_mode: ViewMode::Detailed,
245 collapsed_hosts: HashSet::new(),
246 view_cache: std::cell::RefCell::new(None),
247 }
248 }
249}
250
251impl ContainersOverviewState {
252 pub fn start_refresh(&mut self, batch: RefreshBatch) {
256 self.refresh_batch = Some(batch);
257 }
258
259 pub fn clear_refresh(&mut self) {
262 self.refresh_batch = None;
263 }
264
265 pub fn sort_mode(&self) -> ContainersSortMode {
269 self.sort_mode
270 }
271
272 pub fn set_sort_mode_ephemeral(&mut self, mode: ContainersSortMode) {
275 self.sort_mode = mode;
276 }
277
278 pub fn view_mode(&self) -> ViewMode {
279 self.view_mode
280 }
281
282 pub fn set_view_mode_ephemeral(&mut self, mode: ViewMode) {
285 self.view_mode = mode;
286 }
287
288 pub fn collapsed_hosts(&self) -> &HashSet<String> {
289 &self.collapsed_hosts
290 }
291
292 pub fn toggle_host_collapsed(&mut self, alias: &str) -> bool {
294 if self.collapsed_hosts.remove(alias) {
295 false
296 } else {
297 self.collapsed_hosts.insert(alias.to_string());
298 true
299 }
300 }
301
302 pub fn refresh_batch(&self) -> Option<&RefreshBatch> {
303 self.refresh_batch.as_ref()
304 }
305
306 pub fn refresh_batch_mut(&mut self) -> Option<&mut RefreshBatch> {
307 self.refresh_batch.as_mut()
308 }
309
310 pub fn auto_list_in_flight(&self) -> &HashSet<String> {
311 &self.auto_list_in_flight
312 }
313
314 pub fn auto_list_pending(&self, alias: &str) -> bool {
317 self.auto_list_in_flight.contains(alias)
318 }
319
320 pub fn mark_auto_list_pending(&mut self, alias: String) {
322 self.auto_list_in_flight.insert(alias);
323 }
324
325 pub fn clear_auto_list_pending(&mut self, alias: &str) {
327 self.auto_list_in_flight.remove(alias);
328 }
329
330 pub fn inspect_cache(&self) -> &InspectCache {
331 &self.inspect_cache
332 }
333
334 pub fn inspect_cache_mut(&mut self) -> &mut InspectCache {
335 &mut self.inspect_cache
336 }
337
338 pub fn logs_cache(&self) -> &LogsCache {
339 &self.logs_cache
340 }
341
342 pub fn logs_cache_mut(&mut self) -> &mut LogsCache {
343 &mut self.logs_cache
344 }
345
346 pub fn view_cache(
347 &self,
348 ) -> &std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>
349 {
350 &self.view_cache
351 }
352
353 pub(crate) fn hydrate_from_prefs(&mut self) {
357 self.view_mode = crate::preferences::load_containers_view_mode();
358 self.sort_mode = crate::preferences::load_containers_sort_mode();
359 self.collapsed_hosts = crate::preferences::load_containers_collapsed_hosts();
360 }
361
362 pub fn set_view_mode(&mut self, mode: ViewMode) -> std::io::Result<()> {
367 self.view_mode = mode;
368 crate::preferences::save_containers_view_mode(mode).inspect_err(|e| {
369 log::warn!("[config] Failed to persist containers view mode: {e}");
370 })
371 }
372
373 pub fn set_sort_mode(&mut self, mode: ContainersSortMode) -> std::io::Result<()> {
376 self.sort_mode = mode;
377 crate::preferences::save_containers_sort_mode(mode).inspect_err(|e| {
378 log::warn!("[config] Failed to persist containers sort mode: {e}");
379 })
380 }
381
382 pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
388 if old == new {
389 return false;
390 }
391 if self.auto_list_in_flight.remove(old) {
392 debug_assert!(
393 !self.auto_list_in_flight.contains(new),
394 "auto_list_in_flight collision on rename {old} -> {new}"
395 );
396 self.auto_list_in_flight.insert(new.to_string());
397 }
398 if let Some(batch) = self.refresh_batch.as_mut() {
399 if batch.in_flight_aliases.remove(old) {
400 debug_assert!(
401 !batch.in_flight_aliases.contains(new),
402 "refresh_batch.in_flight_aliases collision on rename {old} -> {new}"
403 );
404 batch.in_flight_aliases.insert(new.to_string());
405 }
406 }
407 if self.collapsed_hosts.remove(old) {
408 debug_assert!(
409 !self.collapsed_hosts.contains(new),
410 "collapsed_hosts collision on rename {old} -> {new}"
411 );
412 self.collapsed_hosts.insert(new.to_string());
413 true
414 } else {
415 false
416 }
417 }
418}
419
420impl InspectCache {
421 pub fn fresh(&self, container_id: &str, now: u64) -> Option<&InspectCacheEntry> {
425 self.entries
426 .get(container_id)
427 .filter(|e| now.saturating_sub(e.timestamp) < INSPECT_CACHE_TTL_SECS)
428 }
429}
430
431impl LogsCache {
432 pub fn fresh(&self, container_id: &str, now: u64) -> Option<&LogsCacheEntry> {
435 self.entries
436 .get(container_id)
437 .filter(|e| now.saturating_sub(e.timestamp) < LOGS_CACHE_TTL_SECS)
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use crate::preferences::tests_helpers::with_temp_prefs;
445
446 fn batch_with_aliases(aliases: &[&str]) -> RefreshBatch {
447 RefreshBatch {
448 queue: VecDeque::new(),
449 in_flight: aliases.len(),
450 total: aliases.len(),
451 completed: 0,
452 in_flight_aliases: aliases.iter().map(|a| a.to_string()).collect(),
453 }
454 }
455
456 #[test]
457 fn start_refresh_installs_batch() {
458 let mut state = ContainersOverviewState::default();
459 assert!(state.refresh_batch.is_none());
460 state.start_refresh(batch_with_aliases(&["host-a", "host-b"]));
461 let batch = state.refresh_batch.as_ref().unwrap();
462 assert_eq!(batch.total, 2);
463 assert_eq!(batch.in_flight, 2);
464 assert!(batch.in_flight_aliases.contains("host-a"));
465 }
466
467 #[test]
468 fn clear_refresh_drops_batch() {
469 let mut state = ContainersOverviewState::default();
470 state.start_refresh(batch_with_aliases(&["host-a"]));
471 state.clear_refresh();
472 assert!(state.refresh_batch.is_none());
473 }
474
475 #[test]
476 fn hydrate_from_prefs_reads_persisted_values() {
477 with_temp_prefs("hydrate_from_prefs", |_path| {
478 crate::preferences::save_containers_view_mode(ViewMode::Compact).unwrap();
479 crate::preferences::save_containers_sort_mode(ContainersSortMode::AlphaContainer)
480 .unwrap();
481 let mut collapsed = std::collections::HashSet::new();
482 collapsed.insert("folded-host".to_string());
483 crate::preferences::save_containers_collapsed_hosts(&collapsed).unwrap();
484
485 let mut state = ContainersOverviewState::default();
486 state.hydrate_from_prefs();
487 assert_eq!(state.view_mode, ViewMode::Compact);
488 assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
489 assert!(state.collapsed_hosts.contains("folded-host"));
490 });
491 }
492
493 #[test]
494 fn set_view_mode_updates_field_and_persists() {
495 with_temp_prefs("set_view_mode", |_path| {
496 let mut state = ContainersOverviewState::default();
497 state.set_view_mode(ViewMode::Compact).unwrap();
498 assert_eq!(state.view_mode, ViewMode::Compact);
499 assert_eq!(
500 crate::preferences::load_containers_view_mode(),
501 ViewMode::Compact
502 );
503 });
504 }
505
506 #[test]
507 fn set_sort_mode_updates_field_and_persists() {
508 with_temp_prefs("set_sort_mode", |_path| {
509 let mut state = ContainersOverviewState::default();
510 state
511 .set_sort_mode(ContainersSortMode::AlphaContainer)
512 .unwrap();
513 assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
514 assert_eq!(
515 crate::preferences::load_containers_sort_mode(),
516 ContainersSortMode::AlphaContainer
517 );
518 });
519 }
520
521 #[test]
522 fn migrate_alias_renames_auto_list_in_flight() {
523 let mut state = ContainersOverviewState::default();
524 state.auto_list_in_flight.insert("old".to_string());
525 state.migrate_alias("old", "new");
526 assert!(state.auto_list_in_flight.contains("new"));
527 assert!(!state.auto_list_in_flight.contains("old"));
528 }
529
530 #[test]
531 fn migrate_alias_renames_refresh_batch_in_flight() {
532 let mut state = ContainersOverviewState::default();
533 state.start_refresh(batch_with_aliases(&["old"]));
534 assert!(!state.migrate_alias("old", "new"));
539 let batch = state.refresh_batch.as_ref().unwrap();
540 assert!(batch.in_flight_aliases.contains("new"));
541 assert!(!batch.in_flight_aliases.contains("old"));
542 }
543
544 #[test]
545 fn migrate_alias_self_rename_is_noop() {
546 let mut state = ContainersOverviewState::default();
547 state.collapsed_hosts.insert("same".to_string());
548 state.auto_list_in_flight.insert("same".to_string());
549 assert!(!state.migrate_alias("same", "same"));
550 assert!(state.collapsed_hosts.contains("same"));
551 assert!(state.auto_list_in_flight.contains("same"));
552 }
553
554 #[test]
555 fn migrate_alias_renames_collapsed_hosts_and_returns_true() {
556 let mut state = ContainersOverviewState::default();
557 state.collapsed_hosts.insert("old".to_string());
558 assert!(state.migrate_alias("old", "new"));
559 assert!(state.collapsed_hosts.contains("new"));
560 assert!(!state.collapsed_hosts.contains("old"));
561 }
562
563 #[test]
564 fn migrate_alias_returns_false_when_collapsed_unchanged() {
565 let mut state = ContainersOverviewState::default();
566 state.auto_list_in_flight.insert("old".to_string());
567 assert!(!state.migrate_alias("old", "new"));
568 assert!(state.auto_list_in_flight.contains("new"));
569 }
570
571 #[test]
572 fn migrate_alias_is_noop_when_nothing_matches() {
573 let mut state = ContainersOverviewState::default();
574 assert!(!state.migrate_alias("missing", "new"));
575 }
576}