• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kdash-rs / kdash / 24138980530

08 Apr 2026 01:50PM UTC coverage: 65.099% (-0.07%) from 65.167%
24138980530

push

github

web-flow
Merge pull request #504 from sed-i/feature/events

feat: add "Events" under the "More" menu

99 of 176 new or added lines in 5 files covered. (56.25%)

1 existing line in 1 file now uncovered.

8125 of 12481 relevant lines covered (65.1%)

137.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

76.91
/src/handlers/mod.rs
1
use crossterm::event::{KeyEvent, MouseEvent, MouseEventKind};
2
use kubectl_view_allocations::GroupBy;
3
use serde::Serialize;
4
use std::{
5
  fs,
6
  path::{Path, PathBuf},
7
};
8

9
use crate::{
10
  app::{
11
    key_binding::DEFAULT_KEYBINDING,
12
    models::{
13
      HasPodSelector, KubeResource, Scrollable, ScrollableTxt, StatefulList, StatefulTable,
14
    },
15
    secrets::KubeSecret,
16
    troubleshoot::ResourceKind,
17
    ActiveBlock, App, Route, RouteId,
18
  },
19
  cmd::IoCmdEvent,
20
  event::Key,
21
};
22

23
/// Handles Enter/`o` key on a workload resource: describe/yaml, drill-down to pods, or aggregate logs.
24
macro_rules! handle_workload_action {
25
  ($key:expr, $app:expr, $field:ident, $kind:expr) => {
26
    if $key == DEFAULT_KEYBINDING.aggregate_logs.key {
27
      // `o` key — aggregate logs from all pods
28
      if let Some(res) = $app.data.$field.get_selected_item_copy() {
29
        if let Some(selector) = res.pod_label_selector() {
30
          $app
31
            .dispatch_aggregate_logs(
32
              res.name.clone(),
33
              res.namespace.clone(),
34
              selector,
35
              $kind.to_owned(),
36
              RouteId::Home,
37
            )
38
            .await;
39
        }
40
      }
41
    } else if let Some(res) = handle_block_action($key, &$app.data.$field) {
42
      let ok = handle_describe_decode_or_yaml_action(
43
        $key,
44
        $app,
45
        &res,
46
        IoCmdEvent::GetDescribe {
47
          kind: $kind.to_owned(),
48
          value: res.name.to_owned(),
49
          ns: Some(res.namespace.to_owned()),
50
        },
51
      )
52
      .await;
53
      if !ok {
54
        // Enter key pressed — drill down to the resource's pods
55
        if let Some(selector) = res.pod_label_selector() {
56
          $app
57
            .dispatch_resource_pods(
58
              res.namespace.clone(),
59
              selector,
60
              $kind.to_owned(),
61
              RouteId::Home,
62
            )
63
            .await;
64
        }
65
      }
66
    }
67
  };
68
}
69

70
/// Dispatches block action (describe/yaml/decode) for standard resource types.
71
/// Wraps the entire match expression. Special-case arms go in the `extra` block.
72
macro_rules! handle_resource_action {
73
  ($match_expr:expr, $key:expr, $app:expr,
74
    namespaced: [ $(($block:path, $field:ident, $kind:expr)),* $(,)? ],
75
    cluster: [ $(($cblock:path, $cfield:ident, $ckind:expr)),* $(,)? ],
76
    extra: { $($extra_arms:tt)* }
77
  ) => {
78
    match $match_expr {
79
      $(
80
        $block => {
81
          if let Some(res) = handle_block_action($key, &$app.data.$field) {
82
            handle_leaf_resource_action(
83
              $key,
84
              $app,
85
              &res,
86
              $kind.to_owned(),
87
              Some(res.namespace.to_owned()),
88
            )
89
            .await;
90
          }
91
        }
92
      )*
93
      $(
94
        $cblock => {
95
          if let Some(res) = handle_block_action($key, &$app.data.$cfield) {
96
            handle_leaf_resource_action($key, $app, &res, $ckind.to_owned(), None).await;
97
          }
98
        }
99
      )*
100
      $($extra_arms)*
101
    }
102
  };
103
}
104

105
/// Dispatches scroll for standard resource types.
106
/// Wraps the entire match expression. Special-case arms go in the `extra` block.
107
macro_rules! handle_resource_scroll {
108
  ($match_expr:expr, $app:expr, $up:expr, $page:expr,
109
    [ $(($block:path, $field:ident)),* $(,)? ],
110
    extra: { $($extra_arms:tt)* }
111
  ) => {
112
    match $match_expr {
113
      $(
114
        $block => $app.data.$field.handle_scroll($up, $page),
115
      )*
116
      $($extra_arms)*
117
    }
118
  };
119
}
120

121
pub async fn handle_key_events(key: Key, key_event: KeyEvent, app: &mut App) {
66✔
122
  let _ = key_event;
66✔
123
  let resource_filter_active = app
66✔
124
    .current_resource_table()
66✔
125
    .is_some_and(|table| table.is_filter_active());
66✔
126

127
  if app.is_menu_active() && app.menu_filter_active && handle_menu_filter_key(key, app) {
66✔
128
    // Menu filter captured the key — done
11✔
129
  } else if app.is_menu_active() && !app.menu_filter_active && key == DEFAULT_KEYBINDING.filter.key
55✔
130
  {
1✔
131
    // Activate menu filter mode
1✔
132
    app.menu_filter_active = true;
1✔
133
  } else if resource_filter_active && handle_resource_filter_key(key, app) {
54✔
134
    // Resource filter captured the key — done
18✔
135
  } else if app.get_current_route().active_block == ActiveBlock::Namespaces
36✔
136
    && app.ns_filter_active
7✔
137
    && handle_namespace_filter_key(key, app)
6✔
138
  {
4✔
139
    // Namespace filter captured the key — done
4✔
140
  } else {
4✔
141
    // First handle any global event and then move to route event
142
    match key {
32✔
143
      _ if key == DEFAULT_KEYBINDING.esc.key => {
32✔
144
        handle_escape(app);
18✔
145
      }
18✔
146
      _ if key == DEFAULT_KEYBINDING.quit.key || key == DEFAULT_KEYBINDING.quit.alt.unwrap() => {
14✔
147
        app.should_quit = true;
×
148
      }
×
149
      // Keep raw arrow navigation working even with remapped keybindings.
150
      // In alternate-screen mode without mouse capture, some terminals translate
151
      // mouse wheel scrolling into Up/Down key events.
152
      _ if key == DEFAULT_KEYBINDING.up.key
14✔
153
        || key == DEFAULT_KEYBINDING.up.alt.unwrap()
14✔
154
        || key == Key::Up =>
14✔
155
      {
156
        handle_block_scroll(app, true, false, false).await;
×
157
      }
158
      _ if key == DEFAULT_KEYBINDING.down.key
14✔
159
        || key == DEFAULT_KEYBINDING.down.alt.unwrap()
14✔
160
        || key == Key::Down =>
14✔
161
      {
162
        handle_block_scroll(app, false, false, false).await;
×
163
      }
164
      _ if key == DEFAULT_KEYBINDING.pg_up.key => {
14✔
165
        handle_block_scroll(app, true, false, true).await;
×
166
      }
167
      _ if key == DEFAULT_KEYBINDING.pg_down.key => {
14✔
168
        handle_block_scroll(app, false, false, true).await;
×
169
      }
170
      _ if key == DEFAULT_KEYBINDING.toggle_theme.key => {
14✔
171
        app.light_theme = !app.light_theme;
×
172
      }
×
173
      _ if key == DEFAULT_KEYBINDING.refresh.key => {
14✔
174
        app.refresh();
×
175
      }
×
176
      _ if key == DEFAULT_KEYBINDING.dump_error_log.key => {
14✔
177
        dump_error_history(app, None);
1✔
178
      }
1✔
179
      _ if key == DEFAULT_KEYBINDING.help.key => {
13✔
180
        if app.get_current_route().active_block != ActiveBlock::Help {
×
181
          app.push_navigation_stack(RouteId::HelpMenu, ActiveBlock::Help);
×
182
        }
×
183
      }
184
      _ if key == DEFAULT_KEYBINDING.jump_to_all_context.key => {
13✔
185
        app.route_contexts();
×
186
      }
×
187
      _ if key == DEFAULT_KEYBINDING.jump_to_current_context.key => {
13✔
188
        app.route_home();
×
189
      }
×
190
      _ if key == DEFAULT_KEYBINDING.jump_to_utilization.key => {
13✔
191
        app.route_utilization();
×
192
      }
×
193
      _ if key == DEFAULT_KEYBINDING.jump_to_troubleshoot.key => {
13✔
194
        app.route_troubleshoot();
×
195
      }
×
196
      _ if key == DEFAULT_KEYBINDING.cycle_main_views.key => {
13✔
197
        app.cycle_main_routes();
×
198
      }
×
199
      _ => handle_route_events(key, app).await,
13✔
200
    }
201
  }
202
}
66✔
203

204
pub async fn handle_mouse_events(mouse: MouseEvent, app: &mut App) {
×
205
  match mouse.kind {
×
206
    // mouse scrolling is inverted
207
    MouseEventKind::ScrollDown => handle_block_scroll(app, true, true, false).await,
×
208
    MouseEventKind::ScrollUp => handle_block_scroll(app, false, true, false).await,
×
209
    _ => {}
×
210
  }
211
}
×
212

213
fn handle_escape(app: &mut App) {
18✔
214
  // dismiss error
215
  if !app.api_error.is_empty() {
18✔
216
    app.api_error = String::default();
×
217
  } else if !app.status_message.is_empty() {
18✔
218
    app.clear_status_message();
×
219
  }
18✔
220

221
  // If menu filter is active, deactivate it first (clear text if any, else deactivate)
222
  if app.is_menu_active() && app.menu_filter_active {
18✔
223
    clear_or_deactivate_filter(&mut app.menu_filter, &mut app.menu_filter_active);
2✔
224
    return;
2✔
225
  }
16✔
226

227
  if app.get_current_route().active_block == ActiveBlock::Namespaces && app.ns_filter_active {
16✔
228
    clear_or_deactivate_filter(&mut app.ns_filter, &mut app.ns_filter_active);
2✔
229
    return;
2✔
230
  }
14✔
231

232
  if let Some((filter, filter_active, _)) = app.current_resource_filter_mut() {
14✔
233
    if *filter_active {
11✔
234
      clear_or_deactivate_filter(filter, filter_active);
10✔
235
      return;
10✔
236
    }
1✔
237
  }
3✔
238

239
  // Clear menu filter state on any menu exit
240
  if app.is_menu_active() {
4✔
241
    app.menu_filter.clear();
1✔
242
    app.menu_filter_active = false;
1✔
243
  }
3✔
244

245
  match app.get_current_route().id {
4✔
246
    RouteId::HelpMenu => {
×
247
      app.pop_navigation_stack();
×
248
    }
×
249
    _ => match app.get_current_route().active_block {
4✔
250
      ActiveBlock::Namespaces
251
      | ActiveBlock::Containers
252
      | ActiveBlock::Yaml
253
      | ActiveBlock::Describe => {
×
254
        app.pop_navigation_stack();
×
255
      }
×
256
      ActiveBlock::Pods if app.data.selected.pod_selector.is_some() => {
1✔
257
        // Exiting a filtered pod view from workload drill-down
1✔
258
        app.data.selected.pod_selector = None;
1✔
259
        app.data.selected.pod_selector_ns = None;
1✔
260
        app.data.selected.pod_selector_resource = None;
1✔
261
        app.pop_navigation_stack();
1✔
262
      }
1✔
263
      ActiveBlock::Logs => {
264
        app.cancel_log_stream();
2✔
265
        // Clear resource context when leaving aggregate logs
266
        if app.data.selected.pod_selector.is_none() {
2✔
267
          app.data.selected.pod_selector_resource = None;
1✔
268
        }
1✔
269
        app.pop_navigation_stack();
2✔
270
      }
271
      _ => {
272
        if let ActiveBlock::More = app.get_prev_route().active_block {
1✔
273
          app.pop_navigation_stack();
×
274
        }
1✔
275
        if let ActiveBlock::DynamicView = app.get_prev_route().active_block {
1✔
276
          app.pop_navigation_stack();
×
277
        }
1✔
278
      }
279
    },
280
  }
281
}
18✔
282

283
/// Handle character/backspace keys for menu filter input.
284
/// Returns true if the key was consumed, false to let it pass through.
285
fn handle_menu_filter_key(key: Key, app: &mut App) -> bool {
14✔
286
  match key {
14✔
287
    Key::Char(c) => {
9✔
288
      app.menu_filter.push(c);
9✔
289
      // Reset selection to first item when filter changes
290
      let menu = get_active_menu_mut(app);
9✔
291
      menu.state.select(Some(0));
9✔
292
      true
9✔
293
    }
294
    Key::Backspace => {
295
      app.menu_filter.pop();
2✔
296
      let menu = get_active_menu_mut(app);
2✔
297
      menu.state.select(Some(0));
2✔
298
      true
2✔
299
    }
300
    _ => false,
3✔
301
  }
302
}
14✔
303

304
fn handle_filter_text_key(filter: &mut String, key: Key) -> bool {
35✔
305
  match key {
35✔
306
    Key::Char(c) => {
21✔
307
      filter.push(c);
21✔
308
      true
21✔
309
    }
310
    Key::Backspace => {
311
      filter.pop();
1✔
312
      true
1✔
313
    }
314
    _ => false,
13✔
315
  }
316
}
35✔
317

318
fn clear_or_deactivate_filter(filter: &mut String, active: &mut bool) {
14✔
319
  if filter.is_empty() {
14✔
320
    *active = false;
7✔
321
  } else {
7✔
322
    filter.clear();
7✔
323
  }
7✔
324
}
14✔
325

326
fn handle_resource_filter_key(key: Key, app: &mut App) -> bool {
29✔
327
  if let Some((filter, _, state)) = app.current_resource_filter_mut() {
29✔
328
    let handled = handle_filter_text_key(filter, key);
29✔
329
    if handled {
29✔
330
      state.select(Some(0));
18✔
331
    }
18✔
332
    handled
29✔
333
  } else {
334
    false
×
335
  }
336
}
29✔
337

338
fn handle_namespace_filter_key(key: Key, app: &mut App) -> bool {
6✔
339
  let handled = handle_filter_text_key(&mut app.ns_filter, key);
6✔
340
  if handled {
6✔
341
    app.data.namespaces.state.select(Some(0));
4✔
342
  }
4✔
343
  handled
6✔
344
}
6✔
345

346
fn get_active_menu_mut(app: &mut App) -> &mut StatefulList<(String, ActiveBlock)> {
11✔
347
  match app.get_current_route().active_block {
11✔
348
    ActiveBlock::DynamicView => &mut app.dynamic_resources_menu,
×
349
    _ => &mut app.more_resources_menu,
11✔
350
  }
351
}
11✔
352

353
/// Filter menu items by the given filter string using case-insensitive substring + glob matching.
354
pub fn filter_menu_items<'a>(
8✔
355
  items: &'a [(String, ActiveBlock)],
8✔
356
  filter: &str,
8✔
357
) -> Vec<(usize, &'a (String, ActiveBlock))> {
8✔
358
  if filter.is_empty() {
8✔
359
    return items.iter().enumerate().collect();
2✔
360
  }
6✔
361
  let filter_lower = filter.to_lowercase();
6✔
362
  items
6✔
363
    .iter()
6✔
364
    .enumerate()
6✔
365
    .filter(|(_, (name, _))| {
27✔
366
      let name_lower = name.to_lowercase();
27✔
367
      name_lower.contains(&filter_lower) || glob_match::glob_match(&filter_lower, &name_lower)
27✔
368
    })
27✔
369
    .collect()
6✔
370
}
8✔
371

372
async fn handle_describe_decode_or_yaml_action<T, S>(
6✔
373
  key: Key,
6✔
374
  app: &mut App,
6✔
375
  res: &T,
6✔
376
  action: IoCmdEvent,
6✔
377
) -> bool
6✔
378
where
6✔
379
  T: KubeResource<S> + 'static,
6✔
380
  S: Serialize,
6✔
381
{
6✔
382
  if key == DEFAULT_KEYBINDING.describe_resource.key {
6✔
383
    app.data.describe_out = ScrollableTxt::new();
1✔
384
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Describe);
1✔
385
    app.dispatch_cmd(action).await;
1✔
386
    true
1✔
387
  } else if key == DEFAULT_KEYBINDING.resource_yaml.key {
5✔
388
    let yaml = res.resource_to_yaml();
1✔
389
    app.data.describe_out = ScrollableTxt::with_string(yaml);
1✔
390
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Yaml);
1✔
391
    true
1✔
392
  } else if key == DEFAULT_KEYBINDING.decode_secret.key {
4✔
393
    // make sure the resources is of type 'KubeSecret'
394
    let of_any = res as &dyn std::any::Any;
1✔
395
    if let Some(secret) = of_any.downcast_ref::<KubeSecret>() {
1✔
396
      let display_output = secret.decode_secret();
1✔
397
      app.data.describe_out = ScrollableTxt::with_string(display_output);
1✔
398
      app.push_navigation_stack(RouteId::Home, ActiveBlock::Describe);
1✔
399
      true
1✔
400
    } else {
401
      // resource is not a secret
402
      false
×
403
    }
404
  } else {
405
    false
3✔
406
  }
407
}
6✔
408

409
async fn handle_leaf_resource_action<T, S>(
1✔
410
  key: Key,
1✔
411
  app: &mut App,
1✔
412
  res: &T,
1✔
413
  kind: String,
1✔
414
  ns: Option<String>,
1✔
415
) where
1✔
416
  T: KubeResource<S> + 'static,
1✔
417
  S: Serialize,
1✔
418
{
1✔
419
  let describe_action = IoCmdEvent::GetDescribe {
1✔
420
    kind,
1✔
421
    value: res.get_name().to_owned(),
1✔
422
    ns,
1✔
423
  };
1✔
424
  let handled = handle_describe_decode_or_yaml_action(key, app, res, describe_action.clone()).await;
1✔
425
  dispatch_describe_on_submit(key, app, handled, describe_action).await;
1✔
426
}
1✔
427

428
async fn dispatch_describe_on_submit(
2✔
429
  key: Key,
2✔
430
  app: &mut App,
2✔
431
  handled: bool,
2✔
432
  describe_action: IoCmdEvent,
2✔
433
) {
2✔
434
  if !handled && key == DEFAULT_KEYBINDING.submit.key {
2✔
435
    app.data.describe_out = ScrollableTxt::new();
2✔
436
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Describe);
2✔
437
    app.dispatch_cmd(describe_action).await;
2✔
438
  }
×
439
}
2✔
440

441
// Handle event for the current active block
442
async fn handle_route_events(key: Key, app: &mut App) {
16✔
443
  // route specific events
444
  match app.get_current_route().id {
16✔
445
    // handle resource tabs on overview
446
    RouteId::Home => {
447
      match key {
9✔
448
        _ if key == DEFAULT_KEYBINDING.right.key
9✔
449
          || key == DEFAULT_KEYBINDING.right.alt.unwrap()
8✔
450
          || key == Key::Right =>
8✔
451
        {
1✔
452
          app.deactivate_current_resource_filter();
1✔
453
          app.context_tabs.next();
1✔
454
          app.push_navigation_route(app.context_tabs.get_active_route().clone());
1✔
455
        }
1✔
456
        _ if key == DEFAULT_KEYBINDING.left.key
8✔
457
          || key == DEFAULT_KEYBINDING.left.alt.unwrap()
8✔
458
          || key == Key::Left =>
8✔
459
        {
×
460
          app.deactivate_current_resource_filter();
×
461
          app.context_tabs.previous();
×
462
          app.push_navigation_route(app.context_tabs.get_active_route().clone());
×
463
        }
×
464
        _ if key == DEFAULT_KEYBINDING.filter.key => {
8✔
465
          if app.get_current_route().active_block == ActiveBlock::Namespaces {
2✔
466
            app.ns_filter_active = true;
1✔
467
          } else if let Some((_, filter_active, _)) = app.current_resource_filter_mut() {
1✔
468
            *filter_active = true;
1✔
469
          }
1✔
470
        }
471
        _ if key == DEFAULT_KEYBINDING.toggle_info.key => {
6✔
472
          app.show_info_bar = !app.show_info_bar;
×
473
        }
×
474
        _ if key == DEFAULT_KEYBINDING.select_all_namespace.key => app.data.selected.ns = None,
6✔
475
        _ if key == DEFAULT_KEYBINDING.jump_to_namespace.key => {
6✔
476
          if app.get_current_route().active_block != ActiveBlock::Namespaces {
×
477
            app.push_navigation_stack(RouteId::Home, ActiveBlock::Namespaces);
×
478
          }
×
479
        }
480
        // as these are tabs with index the order here matters, atleast for readability
481
        _ if key == DEFAULT_KEYBINDING.jump_to_pods.key => {
6✔
482
          // Clear any workload drill-down state so the pod view shows all pods
1✔
483
          app.data.selected.pod_selector = None;
1✔
484
          app.data.selected.pod_selector_ns = None;
1✔
485
          app.data.selected.pod_selector_resource = None;
1✔
486
          app.deactivate_current_resource_filter();
1✔
487
          let route = app.context_tabs.set_index(0).route.clone();
1✔
488
          app.push_navigation_route(route);
1✔
489
        }
1✔
490
        _ if key == DEFAULT_KEYBINDING.jump_to_services.key => {
5✔
491
          app.deactivate_current_resource_filter();
×
492
          let route = app.context_tabs.set_index(1).route.clone();
×
493
          app.push_navigation_route(route);
×
494
        }
×
495
        _ if key == DEFAULT_KEYBINDING.jump_to_nodes.key => {
5✔
496
          app.deactivate_current_resource_filter();
×
497
          let route = app.context_tabs.set_index(2).route.clone();
×
498
          app.push_navigation_route(route);
×
499
        }
×
500
        _ if key == DEFAULT_KEYBINDING.jump_to_configmaps.key => {
5✔
501
          app.deactivate_current_resource_filter();
×
502
          let route = app.context_tabs.set_index(3).route.clone();
×
503
          app.push_navigation_route(route);
×
504
        }
×
505
        _ if key == DEFAULT_KEYBINDING.jump_to_statefulsets.key => {
5✔
506
          app.deactivate_current_resource_filter();
×
507
          let route = app.context_tabs.set_index(4).route.clone();
×
508
          app.push_navigation_route(route);
×
509
        }
×
510
        _ if key == DEFAULT_KEYBINDING.jump_to_replicasets.key => {
5✔
511
          app.deactivate_current_resource_filter();
×
512
          let route = app.context_tabs.set_index(5).route.clone();
×
513
          app.push_navigation_route(route);
×
514
        }
×
515
        _ if key == DEFAULT_KEYBINDING.jump_to_deployments.key => {
5✔
516
          app.deactivate_current_resource_filter();
×
517
          let route = app.context_tabs.set_index(6).route.clone();
×
518
          app.push_navigation_route(route);
×
519
        }
×
520
        _ if key == DEFAULT_KEYBINDING.jump_to_jobs.key => {
5✔
521
          app.deactivate_current_resource_filter();
×
522
          let route = app.context_tabs.set_index(7).route.clone();
×
523
          app.push_navigation_route(route);
×
524
        }
×
525
        _ if key == DEFAULT_KEYBINDING.jump_to_daemonsets.key => {
5✔
526
          app.deactivate_current_resource_filter();
×
527
          let route = app.context_tabs.set_index(8).route.clone();
×
528
          app.push_navigation_route(route);
×
529
        }
×
530
        _ if key == DEFAULT_KEYBINDING.jump_to_more_resources.key => {
5✔
531
          app.deactivate_current_resource_filter();
×
532
          let route = app.context_tabs.set_index(9).route.clone();
×
533
          app.push_navigation_route(route);
×
534
        }
×
535
        _ if key == DEFAULT_KEYBINDING.jump_to_dynamic_resources.key => {
5✔
536
          app.deactivate_current_resource_filter();
×
537
          let route = app.context_tabs.set_index(10).route.clone();
×
538
          app.push_navigation_route(route);
×
539
        }
×
540
        _ => {}
5✔
541
      };
542

543
      // handle block specific stuff
544
      handle_resource_action!(app.get_current_route().active_block, key, app,
9✔
545
        namespaced: [
546
          (ActiveBlock::Services, services, "service"),
×
547
          (ActiveBlock::ConfigMaps, config_maps, "configmap"),
×
548
          (ActiveBlock::Secrets, secrets, "secret"),
1✔
549
          (ActiveBlock::Roles, roles, "roles"),
×
550
          (ActiveBlock::RoleBindings, role_bindings, "rolebindings"),
×
551
          (ActiveBlock::Ingresses, ingress, "ingress"),
×
552
          (ActiveBlock::PersistentVolumeClaims, persistent_volume_claims, "persistentvolumeclaims"),
×
553
          (ActiveBlock::ServiceAccounts, service_accounts, "serviceaccounts"),
×
NEW
554
          (ActiveBlock::Events, events, "event"),
×
UNCOV
555
          (ActiveBlock::NetworkPolicies, network_policies, "networkpolicy"),
×
556
        ],
557
        cluster: [
558
          (ActiveBlock::StorageClasses, storage_classes, "storageclass"),
×
559
          (ActiveBlock::ClusterRoles, cluster_roles, "clusterroles"),
×
560
          (ActiveBlock::ClusterRoleBindings, cluster_role_bindings, "clusterrolebinding"),
×
561
          (ActiveBlock::PersistentVolumes, persistent_volumes, "persistentvolumes"),
×
562
        ],
563
        extra: {
564
          ActiveBlock::Nodes => {
565
            if let Some(res) = handle_block_action(key, &app.data.nodes) {
×
566
              let ok = handle_describe_decode_or_yaml_action(
×
567
                key,
×
568
                app,
×
569
                &res,
×
570
                IoCmdEvent::GetDescribe {
×
571
                  kind: "node".to_owned(),
×
572
                  value: res.name.to_owned(),
×
573
                  ns: None,
×
574
                },
×
575
              )
×
576
              .await;
×
577
              if !ok {
×
578
                app.dispatch_node_pods(res.name.clone(), RouteId::Home).await;
×
579
              }
×
580
            }
×
581
          }
582
          ActiveBlock::Deployments => {
583
            handle_workload_action!(key, app, deployments, "deployment");
×
584
          }
585
          ActiveBlock::StatefulSets => {
586
            handle_workload_action!(key, app, stateful_sets, "statefulset");
×
587
          }
588
          ActiveBlock::ReplicaSets => {
589
            handle_workload_action!(key, app, replica_sets, "replicaset");
×
590
          }
591
          ActiveBlock::Jobs => {
592
            handle_workload_action!(key, app, jobs, "job");
×
593
          }
594
          ActiveBlock::DaemonSets => {
595
            handle_workload_action!(key, app, daemon_sets, "daemonset");
×
596
          }
597
          ActiveBlock::CronJobs => {
598
            handle_workload_action!(key, app, cronjobs, "cronjob");
×
599
          }
600
          ActiveBlock::ReplicationControllers => {
601
            handle_workload_action!(key, app, replication_controllers, "replicationcontroller");
×
602
          }
603
          ActiveBlock::Namespaces => {
604
            if let Some(ns) = handle_block_action(key, &app.data.namespaces) {
1✔
605
              app.data.selected.ns = Some(ns.name);
×
606
              app.cache_essential_data().await;
×
607
              app.queue_background_resource_cache();
×
608
              app.pop_navigation_stack();
×
609
            }
1✔
610
          }
611
          ActiveBlock::Pods => {
612
            if key == DEFAULT_KEYBINDING.aggregate_logs.key {
2✔
613
              if let Some(pod) = app.data.pods.get_selected_item_copy() {
×
614
                app.data.selected.pod = Some(pod.name.clone());
×
615
                app.data.selected.pod_selector_resource = Some("pod".into());
×
616
                app.data.containers.set_items(pod.containers);
×
617
                app.dispatch_pod_logs(pod.name, RouteId::Home).await;
×
618
              }
×
619
            } else if let Some(pod) = handle_block_action(key, &app.data.pods) {
2✔
620
              let ok = handle_describe_decode_or_yaml_action(
×
621
                key,
×
622
                app,
×
623
                &pod,
×
624
                IoCmdEvent::GetDescribe {
×
625
                  kind: "pod".to_owned(),
×
626
                  value: pod.name.to_owned(),
×
627
                  ns: Some(pod.namespace.to_owned()),
×
628
                },
×
629
              )
×
630
              .await;
×
631
              if !ok {
×
632
                app.push_navigation_stack(RouteId::Home, ActiveBlock::Containers);
×
633
                app.data.selected.pod = Some(pod.name);
×
634
                app.data.containers.set_items(pod.containers);
×
635
              }
×
636
            }
2✔
637
          }
638
          ActiveBlock::Containers => {
639
            if let Some(c) = handle_block_action(key, &app.data.containers) {
×
640
              app.data.selected.container = Some(c.name.clone());
×
641
              app.dispatch_container_logs(c.name, RouteId::Home).await;
×
642
            }
×
643
          }
644
          ActiveBlock::Logs => {
NEW
645
            if key == DEFAULT_KEYBINDING.log_auto_scroll.key {
×
NEW
646
              if app.log_auto_scroll {
×
NEW
647
                app.data.logs.freeze_follow_position();
×
NEW
648
              }
×
NEW
649
              app.log_auto_scroll = !app.log_auto_scroll;
×
NEW
650
            } else if key == DEFAULT_KEYBINDING.copy_to_clipboard.key {
×
NEW
651
              copy_to_clipboard(app.data.logs.get_plain_text(), app);
×
NEW
652
            }
×
653
          }
654
          ActiveBlock::Describe | ActiveBlock::Yaml => {
NEW
655
            if key == DEFAULT_KEYBINDING.copy_to_clipboard.key {
×
NEW
656
              copy_to_clipboard(app.data.describe_out.get_txt().to_owned(), app);
×
NEW
657
            }
×
658
          }
659
          ActiveBlock::More => {
660
            if key == DEFAULT_KEYBINDING.submit.key {
2✔
661
              let filtered = filter_menu_items(&app.more_resources_menu.items, &app.menu_filter);
1✔
662
              let selected_item = app
1✔
663
                .more_resources_menu
1✔
664
                .state
1✔
665
                .selected()
1✔
666
                .and_then(|i| filtered.get(i))
1✔
667
                .map(|(_, item)| (*item).clone());
1✔
668
              if let Some((_title, active_block)) = selected_item {
1✔
669
                app.menu_filter.clear();
1✔
670
                app.menu_filter_active = false;
1✔
671
                app.push_navigation_route(Route {
1✔
672
                  id: RouteId::Home,
1✔
673
                  active_block,
1✔
674
                });
1✔
675
              }
1✔
676
            }
1✔
677
          }
678
          ActiveBlock::DynamicView => {
679
            if key == DEFAULT_KEYBINDING.submit.key {
1✔
680
              let filtered = filter_menu_items(&app.dynamic_resources_menu.items, &app.menu_filter);
1✔
681
              let selected_item = app
1✔
682
                .dynamic_resources_menu
1✔
683
                .state
1✔
684
                .selected()
1✔
685
                .and_then(|i| filtered.get(i))
1✔
686
                .map(|(_, item)| (*item).clone());
1✔
687
              if let Some((title, active_block)) = selected_item {
1✔
688
                app.menu_filter.clear();
1✔
689
                app.menu_filter_active = false;
1✔
690
                app.push_navigation_route(Route {
1✔
691
                  id: RouteId::Home,
1✔
692
                  active_block,
1✔
693
                });
1✔
694
                let selected = app.data.dynamic_kinds.iter().find(|it| it.kind == title);
1✔
695
                app.data.selected.dynamic_kind = selected.cloned();
1✔
696
                if !app.apply_cached_dynamic_resources() {
1✔
697
                  app.data.dynamic_resources.set_items(vec![]);
×
698
                }
1✔
699
              }
×
700
            }
×
701
          }
702
          ActiveBlock::DynamicResource => {
703
            if let Some(dynamic_res) = app.data.selected.dynamic_kind.as_ref() {
1✔
704
              if let Some(res) = handle_block_action(key, &app.data.dynamic_resources) {
1✔
705
                let describe_action = IoCmdEvent::GetDescribe {
1✔
706
                  kind: dynamic_res.kind.to_owned(),
1✔
707
                  value: res.name.to_owned(),
1✔
708
                  ns: res.namespace.to_owned(),
1✔
709
                };
1✔
710
                let ok = handle_describe_decode_or_yaml_action(
1✔
711
                  key,
1✔
712
                  app,
1✔
713
                  &res,
1✔
714
                  describe_action.clone(),
1✔
715
                )
1✔
716
                .await;
1✔
717
                dispatch_describe_on_submit(key, app, ok, describe_action).await;
1✔
718
              }
×
719
            }
×
720
          }
721
          ActiveBlock::Contexts | ActiveBlock::Utilization | ActiveBlock::Troubleshoot | ActiveBlock::Help => { /* Do nothing */ }
×
722
        }
723
      )
724
    }
725
    RouteId::Contexts => {
726
      if key == DEFAULT_KEYBINDING.filter.key {
4✔
727
        if let Some((_, filter_active, _)) = app.current_resource_filter_mut() {
1✔
728
          *filter_active = true;
1✔
729
        }
1✔
730
      } else if let Some(ctx) = handle_block_action(key, &app.data.contexts) {
3✔
731
        app.data.selected.context = Some(ctx.name);
3✔
732
        // Pre-select the namespace from the context if one is configured (#90)
3✔
733
        app.data.selected.ns = ctx.namespace;
3✔
734
        app.refresh();
3✔
735
      }
3✔
736
    }
737
    RouteId::Utilization => {
738
      if key == DEFAULT_KEYBINDING.filter.key {
1✔
739
        if let Some((_, filter_active, _)) = app.current_resource_filter_mut() {
1✔
740
          *filter_active = true;
1✔
741
        }
1✔
742
      } else if key == DEFAULT_KEYBINDING.cycle_group_by.key {
×
743
        if app.utilization_group_by.len() == 1 {
×
744
          app.utilization_group_by = vec![
×
745
            GroupBy::resource,
×
746
            GroupBy::node,
×
747
            GroupBy::namespace,
×
748
            GroupBy::pod,
×
749
          ];
×
750
        } else {
×
751
          // keep removing items until just one is left
×
752
          app.utilization_group_by.pop();
×
753
        }
×
754
        app.tick_count = 0; // to force network request
×
755
      }
×
756
    }
757
    RouteId::Troubleshoot => {
758
      if key == DEFAULT_KEYBINDING.filter.key {
1✔
759
        if let Some((_, filter_active, _)) = app.current_resource_filter_mut() {
1✔
760
          *filter_active = true;
1✔
761
          return;
1✔
762
        }
×
763
      }
×
764

765
      match app.get_current_route().active_block {
×
766
        ActiveBlock::Containers => {
767
          if let Some(c) = handle_block_action(key, &app.data.containers) {
×
768
            app.data.selected.container = Some(c.name.clone());
×
769
            app
×
770
              .dispatch_container_logs(c.name, RouteId::Troubleshoot)
×
771
              .await;
×
772
          }
×
773
        }
774
        ActiveBlock::Logs => {
775
          if key == DEFAULT_KEYBINDING.log_auto_scroll.key {
×
776
            if app.log_auto_scroll {
×
777
              app.data.logs.freeze_follow_position();
×
778
            }
×
779
            app.log_auto_scroll = !app.log_auto_scroll;
×
780
          } else if key == DEFAULT_KEYBINDING.copy_to_clipboard.key {
×
781
            copy_to_clipboard(app.data.logs.get_plain_text(), app);
×
782
          }
×
783
        }
784
        ActiveBlock::Troubleshoot => {
785
          if key == DEFAULT_KEYBINDING.submit.key {
×
786
            if let Some(finding) = handle_block_action(key, &app.data.troubleshoot_findings) {
×
787
              if finding.resource_kind == ResourceKind::Pod {
×
788
                // Drill into containers for pod findings
789
                if let Some(idx) = app.data.pods.items.iter().position(|p| {
×
790
                  p.name == finding.describe_name
×
791
                    && finding
×
792
                      .describe_namespace
×
793
                      .as_deref()
×
794
                      .is_some_and(|ns| p.namespace == ns)
×
795
                }) {
×
796
                  let pod = app.data.pods.items[idx].clone();
×
797
                  app.data.pods.state.select(Some(idx));
×
798
                  app.data.selected.pod = Some(pod.name);
×
799
                  app.data.containers.set_items(pod.containers);
×
800
                  app.push_navigation_stack(RouteId::Troubleshoot, ActiveBlock::Containers);
×
801
                }
×
802
              } else {
803
                // Describe for non-pod findings
804
                let (kind, value, ns) = finding.describe_target();
×
805
                app.data.describe_out = ScrollableTxt::new();
×
806
                app.push_navigation_stack(RouteId::Troubleshoot, ActiveBlock::Describe);
×
807
                app
×
808
                  .dispatch_cmd(IoCmdEvent::GetDescribe {
×
809
                    kind: kind.to_owned(),
×
810
                    value: value.to_owned(),
×
811
                    ns: ns.map(str::to_owned),
×
812
                  })
×
813
                  .await;
×
814
              }
815
            }
×
816
          } else if key == DEFAULT_KEYBINDING.describe_resource.key {
×
817
            if let Some(finding) = handle_block_action(key, &app.data.troubleshoot_findings) {
×
818
              let (kind, value, ns) = finding.describe_target();
×
819
              app.data.describe_out = ScrollableTxt::new();
×
820
              app.push_navigation_stack(RouteId::Troubleshoot, ActiveBlock::Describe);
×
821
              app
×
822
                .dispatch_cmd(IoCmdEvent::GetDescribe {
×
823
                  kind: kind.to_owned(),
×
824
                  value: value.to_owned(),
×
825
                  ns: ns.map(str::to_owned),
×
826
                })
×
827
                .await;
×
828
            }
×
829
          } else if key == DEFAULT_KEYBINDING.resource_yaml.key {
×
830
            if let Some(finding) = handle_block_action(key, &app.data.troubleshoot_findings) {
×
831
              let yaml = match finding.resource_kind {
×
832
                ResourceKind::Pod => app
×
833
                  .data
×
834
                  .pods
×
835
                  .items
×
836
                  .iter()
×
837
                  .find(|p| {
×
838
                    p.name == finding.describe_name
×
839
                      && finding
×
840
                        .describe_namespace
×
841
                        .as_deref()
×
842
                        .is_some_and(|ns| p.namespace == ns)
×
843
                  })
×
844
                  .map(|p| p.resource_to_yaml())
×
845
                  .unwrap_or_default(),
×
846
                ResourceKind::Pvc => app
×
847
                  .data
×
848
                  .persistent_volume_claims
×
849
                  .items
×
850
                  .iter()
×
851
                  .find(|pvc| {
×
852
                    pvc.name == finding.describe_name
×
853
                      && finding
×
854
                        .describe_namespace
×
855
                        .as_deref()
×
856
                        .is_some_and(|ns| pvc.namespace == ns)
×
857
                  })
×
858
                  .map(|pvc| pvc.resource_to_yaml())
×
859
                  .unwrap_or_default(),
×
860
                ResourceKind::ReplicaSet => app
×
861
                  .data
×
862
                  .replica_sets
×
863
                  .items
×
864
                  .iter()
×
865
                  .find(|rs| {
×
866
                    rs.name == finding.describe_name
×
867
                      && finding
×
868
                        .describe_namespace
×
869
                        .as_deref()
×
870
                        .is_some_and(|ns| rs.namespace == ns)
×
871
                  })
×
872
                  .map(|rs| rs.resource_to_yaml())
×
873
                  .unwrap_or_default(),
×
874
              };
875
              app.data.describe_out = ScrollableTxt::with_string(yaml);
×
876
              app.push_navigation_stack(RouteId::Troubleshoot, ActiveBlock::Yaml);
×
877
            }
×
878
          }
×
879
        }
880
        _ => {}
×
881
      }
882
    }
883
    RouteId::HelpMenu => {
884
      if key == DEFAULT_KEYBINDING.filter.key {
1✔
885
        if let Some((_, filter_active, _)) = app.current_resource_filter_mut() {
1✔
886
          *filter_active = true;
1✔
887
        }
1✔
888
      }
×
889
    }
890
  }
891
  // reset tick_count so that network requests are made faster
892
  if key == DEFAULT_KEYBINDING.submit.key {
15✔
893
    app.tick_count = 0;
7✔
894
  }
8✔
895
}
16✔
896

897
fn handle_block_action<T: Clone>(key: Key, item: &StatefulTable<T>) -> Option<T> {
9✔
898
  match key {
9✔
899
    _ if key == DEFAULT_KEYBINDING.submit.key
9✔
900
      || key == DEFAULT_KEYBINDING.describe_resource.key
4✔
901
      || key == DEFAULT_KEYBINDING.resource_yaml.key
4✔
902
      || key == DEFAULT_KEYBINDING.decode_secret.key =>
4✔
903
    {
904
      item.get_selected_item_copy()
5✔
905
    }
906
    _ => None,
4✔
907
  }
908
}
9✔
909

910
async fn handle_block_scroll(app: &mut App, up: bool, is_mouse: bool, page: bool) {
3✔
911
  handle_resource_scroll!(app.get_current_route().active_block, app, up, page,
3✔
912
    [
913
      (ActiveBlock::Namespaces, namespaces),
914
      (ActiveBlock::Pods, pods),
915
      (ActiveBlock::Containers, containers),
916
      (ActiveBlock::Services, services),
917
      (ActiveBlock::Nodes, nodes),
918
      (ActiveBlock::ConfigMaps, config_maps),
919
      (ActiveBlock::StatefulSets, stateful_sets),
920
      (ActiveBlock::ReplicaSets, replica_sets),
921
      (ActiveBlock::Deployments, deployments),
922
      (ActiveBlock::Jobs, jobs),
923
      (ActiveBlock::DaemonSets, daemon_sets),
924
      (ActiveBlock::CronJobs, cronjobs),
925
      (ActiveBlock::Secrets, secrets),
926
      (ActiveBlock::ReplicationControllers, replication_controllers),
927
      (ActiveBlock::StorageClasses, storage_classes),
928
      (ActiveBlock::Roles, roles),
929
      (ActiveBlock::RoleBindings, role_bindings),
930
      (ActiveBlock::ClusterRoles, cluster_roles),
931
      (ActiveBlock::ClusterRoleBindings, cluster_role_bindings),
932
      (ActiveBlock::PersistentVolumeClaims, persistent_volume_claims),
933
      (ActiveBlock::PersistentVolumes, persistent_volumes),
934
      (ActiveBlock::Ingresses, ingress),
935
      (ActiveBlock::ServiceAccounts, service_accounts),
936
      (ActiveBlock::Events, events),
937
      (ActiveBlock::NetworkPolicies, network_policies),
938
      (ActiveBlock::DynamicResource, dynamic_resources),
939
    ],
940
    extra: {
941
      ActiveBlock::Contexts => app.data.contexts.handle_scroll(up, page),
×
942
      ActiveBlock::Utilization => app.data.metrics.handle_scroll(up, page),
×
943
      ActiveBlock::Troubleshoot => app.data.troubleshoot_findings.handle_scroll(up, page),
×
944
      ActiveBlock::Help => app.help_docs.handle_scroll(up, page),
×
945
      ActiveBlock::More => {
×
946
        let filtered_len = filter_menu_items(&app.more_resources_menu.items, &app.menu_filter).len();
×
947
        handle_menu_scroll(&mut app.more_resources_menu, up, page, filtered_len);
×
948
      }
×
949
      ActiveBlock::DynamicView => {
×
950
        let filtered_len = filter_menu_items(&app.dynamic_resources_menu.items, &app.menu_filter).len();
×
951
        handle_menu_scroll(&mut app.dynamic_resources_menu, up, page, filtered_len);
×
952
      }
×
953
      ActiveBlock::Logs => {
954
        if app.log_auto_scroll {
1✔
955
          app.data.logs.freeze_follow_position();
1✔
956
          app.log_auto_scroll = false;
1✔
957
        }
1✔
958
        app.data.logs.handle_scroll(inverse_dir(up, is_mouse), page);
1✔
959
      }
960
      ActiveBlock::Describe | ActiveBlock::Yaml => app
×
961
        .data
×
962
        .describe_out
×
963
        .handle_scroll(inverse_dir(up, is_mouse), page),
×
964
    }
965
  )
966
}
3✔
967

968
/// Scroll within a menu, respecting filtered item count
969
fn handle_menu_scroll(
4✔
970
  menu: &mut StatefulList<(String, ActiveBlock)>,
4✔
971
  up: bool,
4✔
972
  page: bool,
4✔
973
  filtered_len: usize,
4✔
974
) {
4✔
975
  if filtered_len == 0 {
4✔
976
    return;
1✔
977
  }
3✔
978
  let increment = if page { 5 } else { 1 };
3✔
979
  let i = match menu.state.selected() {
3✔
980
    Some(i) => {
3✔
981
      if up {
3✔
982
        if i == 0 {
1✔
983
          filtered_len.saturating_sub(increment)
1✔
984
        } else {
985
          i.saturating_sub(increment)
×
986
        }
987
      } else if i >= filtered_len.saturating_sub(increment) {
2✔
988
        0
1✔
989
      } else {
990
        i + increment
1✔
991
      }
992
    }
993
    None => 0,
×
994
  };
995
  menu.state.select(Some(i));
3✔
996
}
4✔
997

998
fn copy_to_clipboard(content: String, app: &mut App) {
×
999
  use std::thread;
1000

1001
  use anyhow::anyhow;
1002
  use copypasta::{ClipboardContext, ClipboardProvider};
1003

1004
  match ClipboardContext::new() {
×
1005
    Ok(mut ctx) => match ctx.set_contents(content) {
×
1006
      // without this sleep the clipboard is not set in some OSes
1007
      Ok(_) => thread::sleep(std::time::Duration::from_millis(100)),
×
1008
      Err(_) => app.handle_error(anyhow!("Unable to set clipboard contents".to_string())),
×
1009
    },
1010
    Err(err) => {
×
1011
      app.handle_error(anyhow!("Unable to obtain clipboard: {}", err));
×
1012
    }
×
1013
  };
1014
}
×
1015

1016
fn dump_error_history(app: &mut App, output_dir: Option<&Path>) {
1✔
1017
  match write_error_history_file(&app.error_history, output_dir) {
1✔
1018
    Ok(path) => app.set_status_message(format!("Saved recent errors to {}", path.display())),
1✔
1019
    Err(error) => app.handle_error(anyhow::anyhow!("Unable to write error log: {}", error)),
×
1020
  }
1021
}
1✔
1022

1023
fn write_error_history_file(
3✔
1024
  history: &std::collections::VecDeque<crate::app::ErrorRecord>,
3✔
1025
  output_dir: Option<&Path>,
3✔
1026
) -> std::io::Result<PathBuf> {
3✔
1027
  let dir = match output_dir {
3✔
1028
    Some(path) => path.to_path_buf(),
2✔
1029
    None => std::env::current_dir()?,
1✔
1030
  };
1031

1032
  let path = dir.join(format!(
3✔
1033
    "kdash-errors-{}.log",
3✔
1034
    chrono::Local::now().format("%Y%m%d%H%M%S")
3✔
1035
  ));
3✔
1036

1037
  fs::write(&path, format_error_history(history))?;
3✔
1038
  Ok(path)
3✔
1039
}
3✔
1040

1041
fn format_error_history(history: &std::collections::VecDeque<crate::app::ErrorRecord>) -> String {
3✔
1042
  if history.is_empty() {
3✔
1043
    "No errors recorded\n".to_owned()
1✔
1044
  } else {
1045
    let mut rendered = history
2✔
1046
      .iter()
2✔
1047
      .map(|record| format!("[{}] {}", record.timestamp, record.message))
3✔
1048
      .collect::<Vec<_>>()
2✔
1049
      .join("\n");
2✔
1050
    rendered.push('\n');
2✔
1051
    rendered
2✔
1052
  }
1053
}
3✔
1054

1055
/// inverse direction for natural scrolling on mouse and keyboard
1056
fn inverse_dir(up: bool, is_mouse: bool) -> bool {
3✔
1057
  if is_mouse {
3✔
1058
    !up
1✔
1059
  } else {
1060
    up
2✔
1061
  }
1062
}
3✔
1063

1064
#[cfg(test)]
1065
mod tests {
1066
  use crossterm::event::KeyCode;
1067
  use k8s_openapi::ByteString;
1068
  use kube::{
1069
    api::ObjectMeta,
1070
    core::{ApiResource, DynamicObject},
1071
    discovery::Scope,
1072
  };
1073
  use std::{
1074
    fs,
1075
    time::{SystemTime, UNIX_EPOCH},
1076
  };
1077
  use tokio::sync::mpsc;
1078

1079
  use super::*;
1080
  use crate::app::{
1081
    contexts::KubeContext,
1082
    dynamic::{dynamic_cache_key, KubeDynamicKind, KubeDynamicResource},
1083
    pods::KubePod,
1084
  };
1085

1086
  #[test]
1087
  fn test_inverse_dir() {
1✔
1088
    assert!(inverse_dir(true, false));
1✔
1089
    assert!(!inverse_dir(true, true));
1✔
1090
  }
1✔
1091

1092
  fn temp_test_dir(name: &str) -> std::path::PathBuf {
3✔
1093
    let suffix = SystemTime::now()
3✔
1094
      .duration_since(UNIX_EPOCH)
3✔
1095
      .unwrap()
3✔
1096
      .as_nanos();
3✔
1097
    let path = std::env::temp_dir().join(format!("kdash-{name}-{suffix}"));
3✔
1098
    fs::create_dir_all(&path).expect("temp test dir should be created");
3✔
1099
    path
3✔
1100
  }
3✔
1101

1102
  #[test]
1103
  fn test_write_error_history_file_writes_recent_errors() {
1✔
1104
    let dir = temp_test_dir("error-dump");
1✔
1105
    let mut app = App::default();
1✔
1106
    app.record_error("first error".into());
1✔
1107
    app.record_error("second error".into());
1✔
1108

1109
    let path = write_error_history_file(&app.error_history, Some(&dir)).unwrap();
1✔
1110
    let contents = fs::read_to_string(path).unwrap();
1✔
1111

1112
    assert!(contents.contains("first error"));
1✔
1113
    assert!(contents.contains("second error"));
1✔
1114
  }
1✔
1115

1116
  #[test]
1117
  fn test_write_error_history_file_writes_empty_message_when_no_errors() {
1✔
1118
    let dir = temp_test_dir("empty-error-dump");
1✔
1119
    let app = App::default();
1✔
1120

1121
    let path = write_error_history_file(&app.error_history, Some(&dir)).unwrap();
1✔
1122
    let contents = fs::read_to_string(path).unwrap();
1✔
1123

1124
    assert_eq!(contents, "No errors recorded\n");
1✔
1125
  }
1✔
1126

1127
  #[tokio::test]
1128
  async fn test_dump_error_key_creates_file_and_sets_status_message() {
1✔
1129
    let dir = temp_test_dir("dump-key");
1✔
1130
    let original_dir = std::env::current_dir().unwrap();
1✔
1131
    std::env::set_current_dir(&dir).unwrap();
1✔
1132

1133
    let mut app = App::default();
1✔
1134
    app.record_error("boom".into());
1✔
1135

1136
    let key_evt = KeyEvent {
1✔
1137
      code: KeyCode::Char('D'),
1✔
1138
      modifiers: crossterm::event::KeyModifiers::SHIFT,
1✔
1139
      kind: crossterm::event::KeyEventKind::Press,
1✔
1140
      state: crossterm::event::KeyEventState::NONE,
1✔
1141
    };
1✔
1142
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1143

1144
    let created_files = fs::read_dir(&dir)
1✔
1145
      .unwrap()
1✔
1146
      .map(|entry| entry.unwrap().file_name().into_string().unwrap())
1✔
1147
      .collect::<Vec<_>>();
1✔
1148

1149
    std::env::set_current_dir(original_dir).unwrap();
1✔
1150

1151
    assert!(created_files
1✔
1152
      .iter()
1✔
1153
      .any(|name| name.starts_with("kdash-errors-") && name.ends_with(".log")));
1✔
1154
    assert!(app.api_error.is_empty());
1✔
1155
    assert!(app.status_message.text().contains("Saved recent errors to"));
1✔
1156
  }
1✔
1157

1158
  #[tokio::test]
1159
  async fn test_resource_filter_key_flow() {
1✔
1160
    let mut app = App::default();
1✔
1161
    app.route_home();
1✔
1162
    assert!(!app.data.pods.filter_active);
1✔
1163
    assert!(app.data.pods.filter.is_empty());
1✔
1164

1165
    let key_evt = KeyEvent::from(KeyCode::Char('/'));
1✔
1166
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1167
    assert!(app.data.pods.filter_active);
1✔
1168

1169
    for c in ['w', 'e', 'b'] {
3✔
1170
      let key_evt = KeyEvent::from(KeyCode::Char(c));
3✔
1171
      handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
3✔
1172
    }
1173
    assert_eq!(app.data.pods.filter, "web");
1✔
1174

1175
    let key_evt = KeyEvent::from(KeyCode::Backspace);
1✔
1176
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1177
    assert_eq!(app.data.pods.filter, "we");
1✔
1178

1179
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1180
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1181
    assert!(app.data.pods.filter_active);
1✔
1182
    assert!(app.data.pods.filter.is_empty());
1✔
1183

1184
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1185
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1186
    assert!(!app.data.pods.filter_active);
1✔
1187
  }
1✔
1188

1189
  #[tokio::test]
1190
  async fn test_tab_switch_deactivates_resource_filter_but_preserves_text() {
1✔
1191
    let mut app = App::default();
1✔
1192
    app.route_home();
1✔
1193
    app.data.pods.filter = "web".into();
1✔
1194
    app.data.pods.filter_active = true;
1✔
1195

1196
    let key_evt = KeyEvent::from(KeyCode::Right);
1✔
1197
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1198

1199
    assert_eq!(app.data.pods.filter, "web");
1✔
1200
    assert!(!app.data.pods.filter_active);
1✔
1201
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Services);
1✔
1202
  }
1✔
1203

1204
  #[tokio::test]
1205
  async fn test_namespace_filter_key_flow() {
1✔
1206
    let mut app = App::default();
1✔
1207
    app.route_home();
1✔
1208
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Namespaces);
1✔
1209

1210
    let key_evt = KeyEvent::from(KeyCode::Char('/'));
1✔
1211
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1212
    assert!(app.ns_filter_active);
1✔
1213

1214
    for c in ['p', 'r', 'o', 'd'] {
4✔
1215
      let key_evt = KeyEvent::from(KeyCode::Char(c));
4✔
1216
      handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
4✔
1217
    }
1218
    assert_eq!(app.ns_filter, "prod");
1✔
1219

1220
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1221
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1222
    assert!(app.ns_filter_active);
1✔
1223
    assert!(app.ns_filter.is_empty());
1✔
1224

1225
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1226
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1227
    assert!(!app.ns_filter_active);
1✔
1228
  }
1✔
1229

1230
  #[tokio::test]
1231
  async fn test_contexts_filter_key_flow() {
1✔
1232
    let mut app = App::default();
1✔
1233
    app.route_contexts();
1✔
1234
    assert!(!app.data.contexts.filter_active);
1✔
1235
    assert!(app.data.contexts.filter.is_empty());
1✔
1236

1237
    let key_evt = KeyEvent::from(KeyCode::Char('/'));
1✔
1238
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1239
    assert!(app.data.contexts.filter_active);
1✔
1240

1241
    for c in ['p', 'r', 'o', 'd'] {
4✔
1242
      let key_evt = KeyEvent::from(KeyCode::Char(c));
4✔
1243
      handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
4✔
1244
    }
1245
    assert_eq!(app.data.contexts.filter, "prod");
1✔
1246

1247
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1248
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1249
    assert!(app.data.contexts.filter_active);
1✔
1250
    assert!(app.data.contexts.filter.is_empty());
1✔
1251

1252
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1253
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1254
    assert!(!app.data.contexts.filter_active);
1✔
1255
  }
1✔
1256

1257
  #[tokio::test]
1258
  async fn test_utilization_filter_key_flow() {
1✔
1259
    let mut app = App::default();
1✔
1260
    app.route_utilization();
1✔
1261
    assert!(!app.data.metrics.filter_active);
1✔
1262
    assert!(app.data.metrics.filter.is_empty());
1✔
1263

1264
    let key_evt = KeyEvent::from(KeyCode::Char('/'));
1✔
1265
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1266
    assert!(app.data.metrics.filter_active);
1✔
1267

1268
    for c in ['c', 'p', 'u'] {
3✔
1269
      let key_evt = KeyEvent::from(KeyCode::Char(c));
3✔
1270
      handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
3✔
1271
    }
1272
    assert_eq!(app.data.metrics.filter, "cpu");
1✔
1273

1274
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1275
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1276
    assert!(app.data.metrics.filter_active);
1✔
1277
    assert!(app.data.metrics.filter.is_empty());
1✔
1278

1279
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1280
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1281
    assert!(!app.data.metrics.filter_active);
1✔
1282
  }
1✔
1283

1284
  #[tokio::test]
1285
  async fn test_troubleshoot_filter_key_flow() {
1✔
1286
    let mut app = App::default();
1✔
1287
    app.route_troubleshoot();
1✔
1288
    assert!(!app.data.troubleshoot_findings.filter_active);
1✔
1289
    assert!(app.data.troubleshoot_findings.filter.is_empty());
1✔
1290

1291
    let key_evt = KeyEvent::from(KeyCode::Char('/'));
1✔
1292
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1293
    assert!(app.data.troubleshoot_findings.filter_active);
1✔
1294

1295
    for c in ['p', 'o', 'd'] {
3✔
1296
      let key_evt = KeyEvent::from(KeyCode::Char(c));
3✔
1297
      handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
3✔
1298
    }
1299
    assert_eq!(app.data.troubleshoot_findings.filter, "pod");
1✔
1300

1301
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1302
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1303
    assert!(app.data.troubleshoot_findings.filter_active);
1✔
1304
    assert!(app.data.troubleshoot_findings.filter.is_empty());
1✔
1305

1306
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1307
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1308
    assert!(!app.data.troubleshoot_findings.filter_active);
1✔
1309
  }
1✔
1310

1311
  #[tokio::test]
1312
  async fn test_help_filter_key_flow() {
1✔
1313
    let mut app = App::default();
1✔
1314
    app.push_navigation_stack(RouteId::HelpMenu, ActiveBlock::Help);
1✔
1315
    assert!(!app.help_docs.filter_active);
1✔
1316
    assert!(app.help_docs.filter.is_empty());
1✔
1317

1318
    let key_evt = KeyEvent::from(KeyCode::Char('/'));
1✔
1319
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1320
    assert!(app.help_docs.filter_active);
1✔
1321

1322
    for c in ['h', 'e', 'l', 'p'] {
4✔
1323
      let key_evt = KeyEvent::from(KeyCode::Char(c));
4✔
1324
      handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
4✔
1325
    }
1326
    assert_eq!(app.help_docs.filter, "help");
1✔
1327

1328
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1329
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1330
    assert!(app.help_docs.filter_active);
1✔
1331
    assert!(app.help_docs.filter.is_empty());
1✔
1332

1333
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1334
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1335
    assert!(!app.help_docs.filter_active);
1✔
1336
  }
1✔
1337

1338
  #[tokio::test]
1339
  async fn test_handle_describe_or_yaml_action() {
1✔
1340
    let mut app = App::default();
1✔
1341

1342
    app.route_home();
1✔
1343
    assert_eq!(app.data.pods.state.selected(), None);
1✔
1344

1345
    let item = KubePod::default();
1✔
1346

1347
    assert!(
1✔
1348
      handle_describe_decode_or_yaml_action(
1✔
1349
        Key::Char('d'),
1✔
1350
        &mut app,
1✔
1351
        &item,
1✔
1352
        IoCmdEvent::GetDescribe {
1✔
1353
          kind: "pod".to_owned(),
1✔
1354
          value: "name".to_owned(),
1✔
1355
          ns: Some("namespace".to_owned()),
1✔
1356
        }
1✔
1357
      )
1✔
1358
      .await
1✔
1359
    );
1360

1361
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Describe);
1✔
1362
    assert_eq!(app.data.describe_out.get_txt(), "");
1✔
1363

1364
    assert!(
1✔
1365
      handle_describe_decode_or_yaml_action(
1✔
1366
        Key::Char('y'),
1✔
1367
        &mut app,
1✔
1368
        &item,
1✔
1369
        IoCmdEvent::GetDescribe {
1✔
1370
          kind: "pod".to_owned(),
1✔
1371
          value: "name".to_owned(),
1✔
1372
          ns: Some("namespace".to_owned()),
1✔
1373
        }
1✔
1374
      )
1✔
1375
      .await
1✔
1376
    );
1377

1378
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Yaml);
1✔
1379
    assert_eq!(
1✔
1380
      app.data.describe_out.get_txt(),
1✔
1381
      "apiVersion: v1\nkind: Pod\nmetadata: {}\n"
1382
    );
1383

1384
    assert!(
1✔
1385
      !handle_describe_decode_or_yaml_action(
1✔
1386
        Key::Char('s'),
1✔
1387
        &mut app,
1✔
1388
        &item,
1✔
1389
        IoCmdEvent::GetDescribe {
1✔
1390
          kind: "pod".to_owned(),
1✔
1391
          value: "name".to_owned(),
1✔
1392
          ns: Some("namespace".to_owned()),
1✔
1393
        }
1✔
1394
      )
1✔
1395
      .await
1✔
1396
    );
1✔
1397
  }
1✔
1398

1399
  #[tokio::test]
1400
  async fn test_decode_secret() {
1✔
1401
    const DATA1: &str = "Hello, World!";
1402
    const DATA2: &str =
1403
      "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit";
1404

1405
    let mut app = App::default();
1✔
1406
    app.route_home();
1✔
1407

1408
    let mut secret = KubeSecret::default();
1✔
1409
    // ByteString base64 encodes the data
1410
    secret
1✔
1411
      .data
1✔
1412
      .insert(String::from("key1"), ByteString(DATA1.as_bytes().into()));
1✔
1413
    secret
1✔
1414
      .data
1✔
1415
      .insert(String::from("key2"), ByteString(DATA2.as_bytes().into()));
1✔
1416

1417
    // ensure that 'x' decodes the secret data
1418
    assert!(
1✔
1419
      handle_describe_decode_or_yaml_action(
1✔
1420
        Key::Char('x'),
1✔
1421
        &mut app,
1✔
1422
        &secret,
1✔
1423
        IoCmdEvent::GetDescribe {
1✔
1424
          kind: "secret".to_owned(),
1✔
1425
          value: "name".to_owned(),
1✔
1426
          ns: Some("namespace".to_owned()),
1✔
1427
        }
1✔
1428
      )
1✔
1429
      .await
1✔
1430
    );
1431

1432
    assert!(app
1✔
1433
      .data
1✔
1434
      .describe_out
1✔
1435
      .get_txt()
1✔
1436
      .contains(format!("key1: {}", DATA1).as_str()));
1✔
1437
    assert!(app
1✔
1438
      .data
1✔
1439
      .describe_out
1✔
1440
      .get_txt()
1✔
1441
      .contains(format!("key2: {}", DATA2).as_str()));
1✔
1442
  }
1✔
1443

1444
  #[tokio::test]
1445
  async fn test_handle_scroll() {
1✔
1446
    let mut app = App::default();
1✔
1447

1448
    app.route_home();
1✔
1449
    assert_eq!(app.data.pods.state.selected(), None);
1✔
1450

1451
    app
1✔
1452
      .data
1✔
1453
      .pods
1✔
1454
      .set_items(vec![KubePod::default(), KubePod::default()]);
1✔
1455

1456
    // mouse scroll
1457
    assert_eq!(app.data.pods.state.selected(), Some(0));
1✔
1458
    handle_block_scroll(&mut app, false, true, false).await;
1✔
1459
    assert_eq!(app.data.pods.state.selected(), Some(1));
1✔
1460
    handle_block_scroll(&mut app, true, true, false).await;
1✔
1461
    assert_eq!(app.data.pods.state.selected(), Some(0));
1✔
1462

1463
    // check logs keyboard scroll
1464
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Logs);
1✔
1465
    assert_eq!(app.data.logs.state.selected(), None);
1✔
1466

1467
    app.data.logs.add_record("record".to_string());
1✔
1468
    app.data.logs.add_record("record 2".to_string());
1✔
1469
    app.data.logs.add_record("record 3".to_string());
1✔
1470

1471
    handle_block_scroll(&mut app, true, false, false).await;
1✔
1472
    assert_eq!(app.data.logs.state.selected(), Some(0));
1✔
1473
  }
1✔
1474

1475
  #[tokio::test]
1476
  async fn test_context_switch() {
1✔
1477
    let mut app = App::default();
1✔
1478
    let ctx = KubeContext {
1✔
1479
      name: "test".into(),
1✔
1480
      ..KubeContext::default()
1✔
1481
    };
1✔
1482
    app.data.contexts.set_items(vec![ctx]);
1✔
1483

1484
    assert_eq!(app.data.selected.context, None);
1✔
1485
    app.route_contexts();
1✔
1486
    handle_route_events(Key::Enter, &mut app).await;
1✔
1487

1488
    assert_eq!(app.data.selected.context, Some("test".into()));
1✔
1489
    assert!(app.refresh);
1✔
1490
  }
1✔
1491

1492
  #[tokio::test]
1493
  async fn test_context_switch_preselects_namespace() {
1✔
1494
    let mut app = App::default();
1✔
1495
    let ctx = KubeContext {
1✔
1496
      name: "prod".into(),
1✔
1497
      namespace: Some("prod-ns".into()),
1✔
1498
      ..KubeContext::default()
1✔
1499
    };
1✔
1500
    app.data.contexts.set_items(vec![ctx]);
1✔
1501

1502
    assert_eq!(app.data.selected.ns, None);
1✔
1503
    app.route_contexts();
1✔
1504
    handle_route_events(Key::Enter, &mut app).await;
1✔
1505

1506
    assert_eq!(app.data.selected.context, Some("prod".into()));
1✔
1507
    assert_eq!(app.data.selected.ns, Some("prod-ns".into()));
1✔
1508
    assert!(app.refresh);
1✔
1509
  }
1✔
1510

1511
  #[tokio::test]
1512
  async fn test_context_switch_no_namespace_clears_ns() {
1✔
1513
    let mut app = App::default();
1✔
1514
    app.data.selected.ns = Some("old-ns".into());
1✔
1515
    let ctx = KubeContext {
1✔
1516
      name: "dev".into(),
1✔
1517
      namespace: None,
1✔
1518
      ..KubeContext::default()
1✔
1519
    };
1✔
1520
    app.data.contexts.set_items(vec![ctx]);
1✔
1521

1522
    app.route_contexts();
1✔
1523
    handle_route_events(Key::Enter, &mut app).await;
1✔
1524

1525
    assert_eq!(app.data.selected.context, Some("dev".into()));
1✔
1526
    assert_eq!(app.data.selected.ns, None);
1✔
1527
    assert!(app.refresh);
1✔
1528
  }
1✔
1529

1530
  #[test]
1531
  fn test_filter_menu_items_empty_filter_returns_all() {
1✔
1532
    let items = vec![
1✔
1533
      ("CronJobs".into(), ActiveBlock::CronJobs),
1✔
1534
      ("Secrets".into(), ActiveBlock::Secrets),
1✔
1535
      ("Roles".into(), ActiveBlock::Roles),
1✔
1536
    ];
1537
    let filtered = filter_menu_items(&items, "");
1✔
1538
    assert_eq!(filtered.len(), 3);
1✔
1539
  }
1✔
1540

1541
  #[test]
1542
  fn test_filter_menu_items_substring_match() {
1✔
1543
    let items = vec![
1✔
1544
      ("CronJobs".into(), ActiveBlock::CronJobs),
1✔
1545
      ("Secrets".into(), ActiveBlock::Secrets),
1✔
1546
      ("Roles".into(), ActiveBlock::Roles),
1✔
1547
    ];
1548
    let filtered = filter_menu_items(&items, "cron");
1✔
1549
    assert_eq!(filtered.len(), 1);
1✔
1550
    assert_eq!(filtered[0].1 .0, "CronJobs");
1✔
1551
  }
1✔
1552

1553
  #[test]
1554
  fn test_filter_menu_items_case_insensitive() {
1✔
1555
    let items = vec![
1✔
1556
      ("CronJobs".into(), ActiveBlock::CronJobs),
1✔
1557
      ("Secrets".into(), ActiveBlock::Secrets),
1✔
1558
    ];
1559
    let filtered = filter_menu_items(&items, "CRON");
1✔
1560
    assert_eq!(filtered.len(), 1);
1✔
1561
    assert_eq!(filtered[0].1 .0, "CronJobs");
1✔
1562
  }
1✔
1563

1564
  #[test]
1565
  fn test_filter_menu_items_glob_match() {
1✔
1566
    let items = vec![
1✔
1567
      ("ClusterRoles".into(), ActiveBlock::ClusterRoles),
1✔
1568
      (
1✔
1569
        "ClusterRoleBinding".into(),
1✔
1570
        ActiveBlock::ClusterRoleBindings,
1✔
1571
      ),
1✔
1572
      ("CronJobs".into(), ActiveBlock::CronJobs),
1✔
1573
    ];
1574
    let filtered = filter_menu_items(&items, "cluster*");
1✔
1575
    assert_eq!(filtered.len(), 2);
1✔
1576
    assert_eq!(filtered[0].1 .0, "ClusterRoles");
1✔
1577
    assert_eq!(filtered[1].1 .0, "ClusterRoleBinding");
1✔
1578
  }
1✔
1579

1580
  #[test]
1581
  fn test_filter_menu_items_no_match() {
1✔
1582
    let items = vec![
1✔
1583
      ("CronJobs".into(), ActiveBlock::CronJobs),
1✔
1584
      ("Secrets".into(), ActiveBlock::Secrets),
1✔
1585
    ];
1586
    let filtered = filter_menu_items(&items, "zzz");
1✔
1587
    assert_eq!(filtered.len(), 0);
1✔
1588
  }
1✔
1589

1590
  #[test]
1591
  fn test_filter_menu_items_preserves_original_index() {
1✔
1592
    let items = vec![
1✔
1593
      ("CronJobs".into(), ActiveBlock::CronJobs),
1✔
1594
      ("Secrets".into(), ActiveBlock::Secrets),
1✔
1595
      ("Roles".into(), ActiveBlock::Roles),
1✔
1596
    ];
1597
    let filtered = filter_menu_items(&items, "role");
1✔
1598
    assert_eq!(filtered.len(), 1);
1✔
1599
    assert_eq!(filtered[0].0, 2); // original index
1✔
1600
  }
1✔
1601

1602
  #[tokio::test]
1603
  async fn test_menu_filter_captures_character_keys() {
1✔
1604
    let mut app = App::default();
1✔
1605
    // Navigate to More menu
1606
    app.push_navigation_stack(RouteId::Home, ActiveBlock::More);
1✔
1607

1608
    // Activate filter mode with '/'
1609
    let key_evt = KeyEvent::from(KeyCode::Char('/'));
1✔
1610
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1611
    assert!(app.menu_filter_active);
1✔
1612

1613
    let key_evt = KeyEvent::from(KeyCode::Char('c'));
1✔
1614
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1615
    assert_eq!(app.menu_filter, "c");
1✔
1616

1617
    let key_evt = KeyEvent::from(KeyCode::Char('r'));
1✔
1618
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1619
    assert_eq!(app.menu_filter, "cr");
1✔
1620
  }
1✔
1621

1622
  #[tokio::test]
1623
  async fn test_menu_filter_requires_slash_to_activate() {
1✔
1624
    let mut app = App::default();
1✔
1625
    app.push_navigation_stack(RouteId::Home, ActiveBlock::More);
1✔
1626

1627
    // Typing without '/' should not filter
1628
    let key_evt = KeyEvent::from(KeyCode::Char('c'));
1✔
1629
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1630
    assert_eq!(app.menu_filter, "");
1✔
1631
    assert!(!app.menu_filter_active);
1✔
1632
  }
1✔
1633

1634
  #[tokio::test]
1635
  async fn test_menu_filter_backspace_removes_char() {
1✔
1636
    let mut app = App::default();
1✔
1637
    app.push_navigation_stack(RouteId::Home, ActiveBlock::More);
1✔
1638
    app.menu_filter_active = true;
1✔
1639

1640
    let key_evt = KeyEvent::from(KeyCode::Char('a'));
1✔
1641
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1642
    let key_evt = KeyEvent::from(KeyCode::Char('b'));
1✔
1643
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1644
    assert_eq!(app.menu_filter, "ab");
1✔
1645

1646
    let key_evt = KeyEvent::from(KeyCode::Backspace);
1✔
1647
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1648
    assert_eq!(app.menu_filter, "a");
1✔
1649
  }
1✔
1650

1651
  #[tokio::test]
1652
  async fn test_menu_filter_backspace_on_empty_does_not_panic() {
1✔
1653
    let mut app = App::default();
1✔
1654
    app.push_navigation_stack(RouteId::Home, ActiveBlock::More);
1✔
1655
    app.menu_filter_active = true;
1✔
1656

1657
    let key_evt = KeyEvent::from(KeyCode::Backspace);
1✔
1658
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1659
    assert_eq!(app.menu_filter, "");
1✔
1660
  }
1✔
1661

1662
  #[tokio::test]
1663
  async fn test_menu_filter_escape_clears_filter_first() {
1✔
1664
    let mut app = App::default();
1✔
1665
    app.push_navigation_stack(RouteId::Home, ActiveBlock::More);
1✔
1666
    app.menu_filter_active = true;
1✔
1667

1668
    // Type a filter
1669
    let key_evt = KeyEvent::from(KeyCode::Char('x'));
1✔
1670
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1671
    assert_eq!(app.menu_filter, "x");
1✔
1672

1673
    // First Escape clears filter but stays in menu
1674
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1675
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1676
    assert_eq!(app.menu_filter, "");
1✔
1677
    assert!(app.menu_filter_active); // still active, just cleared text
1✔
1678
    assert_eq!(app.get_current_route().active_block, ActiveBlock::More);
1✔
1679

1680
    // Second Escape deactivates filter mode
1681
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1682
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1683
    assert!(!app.menu_filter_active);
1✔
1684
    assert_eq!(app.get_current_route().active_block, ActiveBlock::More);
1✔
1685
  }
1✔
1686

1687
  #[tokio::test]
1688
  async fn test_menu_filter_escape_on_empty_closes_menu() {
1✔
1689
    let mut app = App::default();
1✔
1690
    // Push a base route then the menu
1691
    app.push_navigation_stack(RouteId::Home, ActiveBlock::More);
1✔
1692

1693
    // Escape with empty filter
1694
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1695
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1696
    assert_eq!(app.menu_filter, "");
1✔
1697
  }
1✔
1698

1699
  #[tokio::test]
1700
  async fn test_menu_filter_enter_selects_filtered_item() {
1✔
1701
    let mut app = App::default();
1✔
1702
    app.push_navigation_stack(RouteId::Home, ActiveBlock::More);
1✔
1703
    app.menu_filter_active = true;
1✔
1704

1705
    // Type "cron" to filter to CronJobs
1706
    for c in "cron".chars() {
4✔
1707
      let key_evt = KeyEvent::from(KeyCode::Char(c));
4✔
1708
      handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
4✔
1709
    }
1710
    assert_eq!(app.menu_filter, "cron");
1✔
1711

1712
    // Selection should be at 0 (first filtered item)
1713
    assert_eq!(app.more_resources_menu.state.selected(), Some(0));
1✔
1714

1715
    // Press Enter to select
1716
    let key_evt = KeyEvent::from(KeyCode::Enter);
1✔
1717
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1718

1719
    // Should navigate to CronJobs, clear filter, and deactivate filter mode
1720
    assert_eq!(app.menu_filter, "");
1✔
1721
    assert!(!app.menu_filter_active);
1✔
1722
    assert_eq!(app.get_current_route().active_block, ActiveBlock::CronJobs);
1✔
1723
  }
1✔
1724

1725
  #[test]
1726
  fn test_handle_menu_scroll_within_filtered_bounds() {
1✔
1727
    let mut menu = StatefulList::with_items(vec![
1✔
1728
      ("A".into(), ActiveBlock::CronJobs),
1✔
1729
      ("B".into(), ActiveBlock::Secrets),
1✔
1730
      ("C".into(), ActiveBlock::Roles),
1✔
1731
    ]);
1732

1733
    // Scroll down within filtered_len=2
1734
    menu.state.select(Some(0));
1✔
1735
    handle_menu_scroll(&mut menu, false, false, 2);
1✔
1736
    assert_eq!(menu.state.selected(), Some(1));
1✔
1737

1738
    // Scroll down wraps at filtered_len
1739
    handle_menu_scroll(&mut menu, false, false, 2);
1✔
1740
    assert_eq!(menu.state.selected(), Some(0));
1✔
1741

1742
    // Scroll up from 0 wraps to end of filtered
1743
    handle_menu_scroll(&mut menu, true, false, 2);
1✔
1744
    assert_eq!(menu.state.selected(), Some(1));
1✔
1745
  }
1✔
1746

1747
  #[test]
1748
  fn test_handle_menu_scroll_empty_filtered() {
1✔
1749
    let mut menu = StatefulList::with_items(vec![("A".into(), ActiveBlock::CronJobs)]);
1✔
1750
    menu.state.select(Some(0));
1✔
1751
    // Should not panic with filtered_len=0
1752
    handle_menu_scroll(&mut menu, false, false, 0);
1✔
1753
    assert_eq!(menu.state.selected(), Some(0));
1✔
1754
  }
1✔
1755

1756
  #[tokio::test]
1757
  async fn test_dispatch_resource_pods_sets_selector_state() {
1✔
1758
    let mut app = App::default();
1✔
1759
    app.route_home();
1✔
1760

1761
    app
1✔
1762
      .dispatch_resource_pods(
1✔
1763
        "default".into(),
1✔
1764
        "app=nginx".into(),
1✔
1765
        "deployment".into(),
1✔
1766
        RouteId::Home,
1✔
1767
      )
1✔
1768
      .await;
1✔
1769

1770
    assert_eq!(app.data.selected.pod_selector, Some("app=nginx".into()));
1✔
1771
    assert_eq!(app.data.selected.pod_selector_ns, Some("default".into()));
1✔
1772
    assert_eq!(
1✔
1773
      app.data.selected.pod_selector_resource,
1774
      Some("deployment".into())
1✔
1775
    );
1776
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Pods);
1✔
1777
  }
1✔
1778

1779
  #[tokio::test]
1780
  async fn test_dispatch_aggregate_logs_sets_state() {
1✔
1781
    let mut app = App::default();
1✔
1782
    app.route_home();
1✔
1783

1784
    app
1✔
1785
      .dispatch_aggregate_logs(
1✔
1786
        "my-deploy".into(),
1✔
1787
        "default".into(),
1✔
1788
        "app=nginx".into(),
1✔
1789
        "deployment".into(),
1✔
1790
        RouteId::Home,
1✔
1791
      )
1✔
1792
      .await;
1✔
1793

1794
    assert_eq!(app.data.logs.id, "agg:my-deploy");
1✔
1795
    assert_eq!(
1✔
1796
      app.data.selected.pod_selector_resource,
1797
      Some("deployment".into())
1✔
1798
    );
1799
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Logs);
1✔
1800
  }
1✔
1801

1802
  #[tokio::test]
1803
  async fn test_escape_from_filtered_pods_clears_selector_state() {
1✔
1804
    let mut app = App::default();
1✔
1805
    app.route_home();
1✔
1806

1807
    // Simulate drill-down state
1808
    app.data.selected.pod_selector = Some("app=nginx".into());
1✔
1809
    app.data.selected.pod_selector_ns = Some("default".into());
1✔
1810
    app.data.selected.pod_selector_resource = Some("deployment".into());
1✔
1811
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Pods);
1✔
1812

1813
    // Press Esc
1814
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1815
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1816

1817
    assert_eq!(app.data.selected.pod_selector, None);
1✔
1818
    assert_eq!(app.data.selected.pod_selector_ns, None);
1✔
1819
    assert_eq!(app.data.selected.pod_selector_resource, None);
1✔
1820
  }
1✔
1821

1822
  #[tokio::test]
1823
  async fn test_escape_from_aggregate_logs_clears_resource_context() {
1✔
1824
    let mut app = App::default();
1✔
1825
    app.route_home();
1✔
1826

1827
    // Simulate aggregate logs state (no pod_selector set)
1828
    app.data.selected.pod_selector_resource = Some("deployment".into());
1✔
1829
    app.data.logs = crate::app::models::LogsState::new("agg:my-deploy".into());
1✔
1830
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Logs);
1✔
1831

1832
    // Press Esc
1833
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1834
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1835

1836
    // Resource context should be cleared for aggregate logs
1837
    assert_eq!(app.data.selected.pod_selector_resource, None);
1✔
1838
  }
1✔
1839

1840
  #[tokio::test]
1841
  async fn test_escape_from_drilldown_logs_preserves_resource_context() {
1✔
1842
    let mut app = App::default();
1✔
1843
    app.route_home();
1✔
1844

1845
    // Simulate drill-down: Deployment → Pods → Container → Logs
1846
    app.data.selected.pod_selector = Some("app=nginx".into());
1✔
1847
    app.data.selected.pod_selector_ns = Some("default".into());
1✔
1848
    app.data.selected.pod_selector_resource = Some("deployment".into());
1✔
1849
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Pods);
1✔
1850
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Containers);
1✔
1851
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Logs);
1✔
1852

1853
    // Press Esc from Logs — should go back to Containers, resource context preserved
1854
    let key_evt = KeyEvent::from(KeyCode::Esc);
1✔
1855
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1856

1857
    assert_eq!(
1✔
1858
      app.data.selected.pod_selector_resource,
1859
      Some("deployment".into())
1✔
1860
    );
1861
    assert_eq!(app.data.selected.pod_selector, Some("app=nginx".into()));
1✔
1862
  }
1✔
1863

1864
  #[tokio::test]
1865
  async fn test_jump_to_pods_clears_selector_state() {
1✔
1866
    let mut app = App::default();
1✔
1867
    app.route_home();
1✔
1868

1869
    // Simulate leftover drill-down state
1870
    app.data.selected.pod_selector = Some("app=nginx".into());
1✔
1871
    app.data.selected.pod_selector_ns = Some("default".into());
1✔
1872
    app.data.selected.pod_selector_resource = Some("deployment".into());
1✔
1873

1874
    // Press '1' to jump to pods tab
1875
    let key_evt = KeyEvent::from(KeyCode::Char('1'));
1✔
1876
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1877

1878
    assert_eq!(app.data.selected.pod_selector, None);
1✔
1879
    assert_eq!(app.data.selected.pod_selector_ns, None);
1✔
1880
    assert_eq!(app.data.selected.pod_selector_resource, None);
1✔
1881
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Pods);
1✔
1882
  }
1✔
1883

1884
  #[tokio::test]
1885
  async fn test_enter_on_leaf_resource_runs_describe() {
1✔
1886
    let mut app = App::default();
1✔
1887
    app.route_home();
1✔
1888

1889
    // Navigate to Secrets (a leaf resource with no child views)
1890
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Secrets);
1✔
1891

1892
    let mut secret = KubeSecret::default();
1✔
1893
    secret.name = "my-secret".into();
1✔
1894
    secret.namespace = "default".into();
1✔
1895
    app.data.secrets.set_items(vec![secret]);
1✔
1896

1897
    // Press Enter
1898
    let key_evt = KeyEvent::from(KeyCode::Enter);
1✔
1899
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1900

1901
    // Should navigate to Describe view
1902
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Describe);
1✔
1903
  }
1✔
1904

1905
  #[tokio::test]
1906
  async fn test_dispatch_node_pods_sets_state() {
1✔
1907
    let mut app = App::default();
1✔
1908
    app.route_home();
1✔
1909

1910
    app
1✔
1911
      .dispatch_node_pods("my-node-01".into(), RouteId::Home)
1✔
1912
      .await;
1✔
1913

1914
    assert_eq!(app.data.selected.pod_selector, Some("my-node-01".into()));
1✔
1915
    assert_eq!(app.data.selected.pod_selector_ns, None);
1✔
1916
    assert_eq!(app.data.selected.pod_selector_resource, Some("node".into()));
1✔
1917
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Pods);
1✔
1918
  }
1✔
1919

1920
  #[tokio::test]
1921
  async fn test_dynamic_view_selection_uses_cached_items_immediately() {
1✔
1922
    let mut app = App::default();
1✔
1923
    app.push_navigation_stack(RouteId::Home, ActiveBlock::DynamicView);
1✔
1924
    app.dynamic_resources_menu =
1✔
1925
      StatefulList::with_items(vec![("Widget".into(), ActiveBlock::DynamicResource)]);
1✔
1926
    app.dynamic_resources_menu.state.select(Some(0));
1✔
1927

1928
    let kind = KubeDynamicKind::new(
1✔
1929
      ApiResource {
1✔
1930
        group: "example.com".into(),
1✔
1931
        version: "v1".into(),
1✔
1932
        api_version: "example.com/v1".into(),
1✔
1933
        kind: "Widget".into(),
1✔
1934
        plural: "widgets".into(),
1✔
1935
      },
1✔
1936
      Scope::Namespaced,
1✔
1937
    );
1938
    app.data.dynamic_kinds = vec![kind.clone()];
1✔
1939
    app.data.selected.ns = Some("team-a".into());
1✔
1940

1941
    let cached_items = vec![KubeDynamicResource::from(DynamicObject {
1✔
1942
      types: None,
1✔
1943
      metadata: ObjectMeta {
1✔
1944
        name: Some("widget-1".into()),
1✔
1945
        namespace: Some("team-a".into()),
1✔
1946
        ..Default::default()
1✔
1947
      },
1✔
1948
      data: Default::default(),
1✔
1949
    })];
1✔
1950
    app.data.dynamic_resource_cache.insert(
1✔
1951
      dynamic_cache_key(&kind, Some("team-a")),
1✔
1952
      cached_items.clone(),
1✔
1953
    );
1954

1955
    let key_evt = KeyEvent::from(KeyCode::Enter);
1✔
1956
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
1957

1958
    assert_eq!(
1✔
1959
      app.get_current_route().active_block,
1✔
1960
      ActiveBlock::DynamicResource
1961
    );
1962
    assert_eq!(
1✔
1963
      app
1✔
1964
        .data
1✔
1965
        .selected
1✔
1966
        .dynamic_kind
1✔
1967
        .as_ref()
1✔
1968
        .map(|it| it.kind.as_str()),
1✔
1969
      Some("Widget")
1970
    );
1971
    assert_eq!(app.data.dynamic_resources.items, cached_items);
1✔
1972
  }
1✔
1973

1974
  #[tokio::test]
1975
  async fn test_enter_on_dynamic_resource_runs_describe() {
1✔
1976
    let (sync_io_tx, _sync_io_rx) = mpsc::channel(10);
1✔
1977
    let (sync_io_stream_tx, _sync_io_stream_rx) = mpsc::channel(10);
1✔
1978
    let (sync_io_cmd_tx, mut sync_io_cmd_rx) = mpsc::channel::<IoCmdEvent>(10);
1✔
1979
    let mut app = App::new(
1✔
1980
      sync_io_tx,
1✔
1981
      sync_io_stream_tx,
1✔
1982
      sync_io_cmd_tx,
1✔
1983
      false,
1984
      1,
1985
      App::default().log_tail_lines,
1✔
1986
      crate::config::KdashConfig::default(),
1✔
1987
    );
1988
    app.push_navigation_stack(RouteId::Home, ActiveBlock::DynamicResource);
1✔
1989

1990
    let kind = KubeDynamicKind::new(
1✔
1991
      ApiResource {
1✔
1992
        group: "example.com".into(),
1✔
1993
        version: "v1".into(),
1✔
1994
        api_version: "example.com/v1".into(),
1✔
1995
        kind: "Widget".into(),
1✔
1996
        plural: "widgets".into(),
1✔
1997
      },
1✔
1998
      Scope::Namespaced,
1✔
1999
    );
2000
    app.data.selected.dynamic_kind = Some(kind);
1✔
2001
    app.data.dynamic_resources =
1✔
2002
      StatefulTable::with_items(vec![KubeDynamicResource::from(DynamicObject {
1✔
2003
        types: None,
1✔
2004
        metadata: ObjectMeta {
1✔
2005
          name: Some("widget-1".into()),
1✔
2006
          namespace: Some("team-a".into()),
1✔
2007
          ..Default::default()
1✔
2008
        },
1✔
2009
        data: Default::default(),
1✔
2010
      })]);
1✔
2011

2012
    let key_evt = KeyEvent::from(KeyCode::Enter);
1✔
2013
    handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
1✔
2014

2015
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Describe);
1✔
2016
    assert_eq!(
1✔
2017
      sync_io_cmd_rx.recv().await.unwrap(),
1✔
2018
      IoCmdEvent::GetDescribe {
1✔
2019
        kind: "Widget".into(),
1✔
2020
        value: "widget-1".into(),
1✔
2021
        ns: Some("team-a".into()),
1✔
2022
      }
1✔
2023
    );
1✔
2024
  }
1✔
2025
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc