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

kdash-rs / kdash / 24324833773

13 Apr 2026 03:52AM UTC coverage: 71.599% (-0.1%) from 71.732%
24324833773

Pull #515

github

web-flow
Merge bc5ffebe2 into 5eb37a632
Pull Request #515: feat(ui): more efficient redraw

79 of 164 new or added lines in 3 files covered. (48.17%)

4 existing lines in 2 files now uncovered.

10011 of 13982 relevant lines covered (71.6%)

147.66 hits per line

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

27.27
/src/main.rs
1
#![warn(rust_2018_idioms)]
2
#[deny(clippy::shadow_unrelated)]
3
mod app;
4
mod banner;
5
mod cmd;
6
mod config;
7
mod event;
8
mod handlers;
9
mod network;
10
mod ui;
11

12
use std::{
13
  fs::File,
14
  io::{self, stdout, Stdout},
15
  panic::{self, PanicHookInfo},
16
  sync::Arc,
17
};
18

19
use anyhow::{anyhow, Result};
20
use app::{key_binding::initialize_keybindings, App, DEFAULT_LOG_TAIL_LINES};
21
use banner::BANNER;
22
use chrono::{self};
23
use clap::{builder::PossibleValuesParser, Parser};
24
use cmd::{
25
  shell::{prepare_shell_exec, run_shell_exec, ShellExecTarget},
26
  CmdRunner, IoCmdEvent,
27
};
28
use config::load_config;
29
use crossterm::{
30
  event::{KeyEvent, MouseEvent},
31
  execute,
32
  terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
33
};
34
use event::Key;
35
use log::{info, warn, LevelFilter, SetLoggerError};
36
use network::{
37
  get_client,
38
  stream::{IoStreamEvent, NetworkStream},
39
  IoEvent, Network,
40
};
41
use ratatui::{
42
  backend::{Backend, CrosstermBackend},
43
  Terminal,
44
};
45
use simplelog::{Config, WriteLogger};
46
use tokio::sync::{mpsc, Mutex};
47
use ui::theme::initialize_theme;
48

49
/// kdash CLI
50
#[derive(Parser, Debug)]
51
#[command(author, version, about, long_about = None, override_usage = "Press `?` while running the app to see keybindings", before_help = BANNER)]
52
pub struct Cli {
53
  /// Set the tick rate (milliseconds): the lower the number the higher the FPS.
54
  #[arg(short, long, value_parser, default_value_t = 250)]
55
  pub tick_rate: u64,
56
  /// Set the network call polling rate (milliseconds, should be multiples of tick-rate):
57
  /// the lower the number the higher the network calls.
58
  #[arg(short, long, value_parser, default_value_t = 5000)]
59
  pub poll_rate: u64,
60
  /// whether unicode symbols are used to improve the overall look of the app
61
  #[arg(short, long, value_parser, default_value_t = true)]
62
  pub enhanced_graphics: bool,
63
  /// Enables debug mode and writes logs to 'kdash-debug-<timestamp>.log' file in the current directory.
64
  /// Default behavior is to write INFO logs. Pass a log level to overwrite the default.
65
  #[arg(
66
    name = "debug",
67
    short,
68
    long,
69
    default_missing_value = "Info",
70
    require_equals = true,
71
    num_args = 0..=1,
72
    ignore_case = true,
73
    value_parser = PossibleValuesParser::new(&["info", "debug", "trace", "warn", "error"])
74
  )]
75
  pub debug: Option<String>,
76
  /// Set how many historical log lines to fetch before live streaming starts.
77
  #[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
78
  pub log_tail_lines: Option<u32>,
79
}
80

81
#[tokio::main]
82
async fn main() -> Result<()> {
×
83
  // SAFETY: safe as this is called once at startup before spawning threads
84
  unsafe { openssl_probe::try_init_openssl_env_vars() };
×
85
  panic::set_hook(Box::new(|info| {
×
86
    panic_hook(info);
×
87
  }));
×
88

89
  // parse CLI arguments
90
  let cli = Cli::parse();
×
91

92
  // Setup logging if debug flag is set
93
  if cli.debug.is_some() {
×
94
    setup_logging(cli.debug.clone())?;
×
95
    info!(
×
96
      "Debug mode is enabled. Level: {}, KDash version: {}",
97
      cli.debug.clone().unwrap(),
×
98
      env!("CARGO_PKG_VERSION")
99
    );
100
  }
×
101

102
  if cli.tick_rate >= 1000 {
×
103
    panic!("Tick rate must be below 1000");
×
104
  }
×
105
  if (cli.poll_rate % cli.tick_rate) > 0u64 {
×
106
    panic!("Poll rate must be multiple of tick-rate");
×
107
  }
×
108

109
  // channels for communication between network/cmd threads & UI thread
110
  let (sync_io_tx, sync_io_rx) = mpsc::channel::<IoEvent>(500);
×
111
  let (sync_io_stream_tx, sync_io_stream_rx) = mpsc::channel::<IoStreamEvent>(500);
×
112
  let (sync_io_cmd_tx, sync_io_cmd_rx) = mpsc::channel::<IoCmdEvent>(500);
×
113
  let loaded_config = load_config();
×
114
  let log_tail_lines = resolve_log_tail_lines(cli.log_tail_lines, &loaded_config.config);
×
115
  let mut config_warnings = vec![];
×
116
  if let Some(warning) = loaded_config.warning.clone() {
×
117
    config_warnings.push(warning);
×
118
  }
×
119
  config_warnings.extend(initialize_keybindings(&loaded_config.config));
×
120
  config_warnings.extend(initialize_theme(&loaded_config.config));
×
121

122
  // Initialize app state
123
  let app = Arc::new(Mutex::new(App::new(
×
124
    sync_io_tx,
×
125
    sync_io_stream_tx,
×
126
    sync_io_cmd_tx,
×
127
    cli.enhanced_graphics,
×
128
    cli.poll_rate / cli.tick_rate,
×
129
    log_tail_lines,
×
130
    loaded_config.config,
×
131
  )));
132

133
  {
134
    let app = app.lock().await;
×
135
    if app.config.keybindings.is_some() || app.config.theme.is_some() {
×
136
      info!("Loaded config overrides from file");
×
137
    }
×
138
  }
139

140
  if !config_warnings.is_empty() {
×
141
    let mut app = app.lock().await;
×
142
    app.handle_error(anyhow!(config_warnings.join(" | ")));
×
143
  }
×
144

145
  // Launch network, stream, and cmd tasks on a dedicated tokio runtime running
146
  // on its own OS thread.  This keeps all network I/O off the main runtime so
147
  // the UI loop is never starved of CPU time by long-running API calls.
148
  let app_nw = Arc::clone(&app);
×
149
  let app_stream = Arc::clone(&app);
×
150
  let app_cli = Arc::clone(&app);
×
151

152
  std::thread::spawn(move || {
×
153
    let rt = tokio::runtime::Builder::new_multi_thread()
×
154
      .enable_all()
×
155
      .thread_name("kdash-network")
×
156
      .build()
×
157
      .expect("Failed to create network runtime");
×
158

159
    rt.block_on(async move {
×
160
      tokio::spawn(async move {
×
161
        info!("Starting network task");
×
162
        start_network(sync_io_rx, &app_nw).await;
×
163
      });
×
164

165
      tokio::spawn(async move {
×
166
        info!("Starting network stream task");
×
167
        start_stream_network(sync_io_stream_rx, &app_stream).await;
×
168
      });
×
169

170
      tokio::spawn(async move {
×
171
        info!("Starting cmd runner task");
×
172
        start_cmd_runner(sync_io_cmd_rx, &app_cli).await;
×
173
      });
×
174

175
      // Keep this runtime alive until all tasks complete.
176
      // When the UI exits and drops the channel senders, recv() returns None
177
      // and the tasks finish naturally.
178
      tokio::signal::ctrl_c()
×
179
        .await
×
180
        .expect("Failed to listen for ctrl_c");
×
181
    });
×
182
  });
×
183

184
  // Launch the UI on the main runtime — it owns the terminal and must run here
185
  start_ui(cli, &app).await?;
×
186

187
  Ok(())
×
188
}
×
189

190
async fn start_network(mut io_rx: mpsc::Receiver<IoEvent>, app: &Arc<Mutex<App>>) {
×
191
  match get_client(None).await {
×
192
    Ok(client) => {
×
193
      let mut network = Network::new(client, app);
×
194

195
      while let Some(io_event) = io_rx.recv().await {
×
196
        info!("Network event received: {:?}", io_event);
×
197
        network.handle_network_event(io_event).await;
×
198
      }
199
    }
200
    Err(e) => {
×
201
      let mut app = app.lock().await;
×
202
      app.handle_error(anyhow!("Unable to obtain Kubernetes client. {}", e));
×
203
    }
204
  }
205
}
×
206

207
fn resolve_log_tail_lines(cli_value: Option<u32>, config: &config::KdashConfig) -> u32 {
3✔
208
  cli_value
3✔
209
    .or(config.log_tail_lines)
3✔
210
    .unwrap_or(DEFAULT_LOG_TAIL_LINES)
3✔
211
}
3✔
212

213
async fn start_stream_network(mut io_rx: mpsc::Receiver<IoStreamEvent>, app: &Arc<Mutex<App>>) {
×
214
  match get_client(None).await {
×
215
    Ok(client) => {
×
216
      let mut network = NetworkStream::new(client, app);
×
217

218
      while let Some(io_event) = io_rx.recv().await {
×
219
        info!("Network stream event received: {:?}", io_event);
×
220
        network.handle_network_stream_event(io_event).await;
×
221
      }
222
    }
223
    Err(e) => {
×
224
      let mut app = app.lock().await;
×
225
      app.handle_error(anyhow!("Unable to obtain Kubernetes client. {}", e));
×
226
    }
227
  }
228
}
×
229

230
async fn start_cmd_runner(mut io_rx: mpsc::Receiver<IoCmdEvent>, app: &Arc<Mutex<App>>) {
×
231
  let mut cmd = CmdRunner::new(app);
×
232

233
  while let Some(io_event) = io_rx.recv().await {
×
234
    info!("Cmd event received: {:?}", io_event);
×
235
    cmd.handle_cmd_event(io_event).await;
×
236
  }
237
}
×
238

239
/// Process a single UI event.  Returns `true` when the app should exit (Ctrl+C).
NEW
240
async fn process_event(
×
NEW
241
  app: &mut App,
×
NEW
242
  ev: event::Event<KeyEvent, MouseEvent>,
×
NEW
243
  is_first_render: &mut bool,
×
NEW
244
) -> bool {
×
NEW
245
  match ev {
×
NEW
246
    event::Event::Input(key_event) => {
×
NEW
247
      let key = Key::from(key_event);
×
NEW
248
      if key == Key::Ctrl('c') {
×
NEW
249
        true
×
250
      } else {
NEW
251
        handlers::handle_key_events(key, key_event, app).await;
×
NEW
252
        false
×
253
      }
254
    }
NEW
255
    event::Event::MouseInput(mouse) => {
×
NEW
256
      handlers::handle_mouse_events(mouse, app).await;
×
NEW
257
      false
×
258
    }
259
    event::Event::Tick => {
NEW
260
      app.on_tick(*is_first_render).await;
×
NEW
261
      *is_first_render = false;
×
NEW
262
      false
×
263
    }
264
    event::Event::KubeConfigChange => {
NEW
265
      info!("Kubeconfig change detected, reloading");
×
NEW
266
      app.dispatch(IoEvent::GetKubeConfig).await;
×
NEW
267
      false
×
268
    }
269
  }
NEW
270
}
×
271

272
async fn start_ui(cli: Cli, app: &Arc<Mutex<App>>) -> Result<()> {
×
273
  info!("Starting UI");
×
274
  // see https://docs.rs/crossterm/0.17.7/crossterm/terminal/#raw-mode
275
  enable_raw_mode()?;
×
276
  // Terminal initialization
277
  let mut stdout = stdout();
×
278
  // not capturing mouse to make text select/copy possible
279
  execute!(stdout, EnterAlternateScreen)?;
×
280
  // terminal backend for cross platform support
281
  let backend = CrosstermBackend::new(stdout);
×
282
  let mut terminal = Terminal::new(backend)?;
×
283
  terminal.clear()?;
×
284
  terminal.hide_cursor()?;
×
285
  // custom events
286
  let mut events = event::Events::new(cli.tick_rate);
×
287
  let mut is_first_render = true;
×
288
  // Perform initial draw so the user sees the UI immediately
289
  {
NEW
290
    let mut app = app.lock().await;
×
NEW
291
    if let Ok(size) = terminal.backend().size() {
×
NEW
292
      app.size.width = size.width;
×
NEW
293
      app.size.height = size.height;
×
NEW
294
    }
×
NEW
295
    terminal.draw(|f| ui::draw(f, &mut app))?;
×
296
  }
297
  // main UI loop
298
  loop {
299
    // Wait for the next event BEFORE acquiring the lock.
300
    // This is the blocking call — no reason to hold the mutex while waiting.
301
    let event = events.next()?;
×
302

303
    let (pending_shell_exec, should_quit) = {
×
304
      let mut app = app.lock().await;
×
305

306
      // Handle events BEFORE drawing so the frame always reflects
307
      // the latest state, eliminating the 1-event visual lag.
308

309
      // Process the blocking event
NEW
310
      let mut should_break = process_event(&mut app, event, &mut is_first_render).await;
×
311

312
      // Drain any pending events so rapid key-presses are batched into a
313
      // single render pass instead of each triggering a stale redraw.
NEW
314
      if !should_break {
×
NEW
315
        for _ in 0..20 {
×
NEW
316
          match events.try_next() {
×
NEW
317
            Some(ev) => {
×
NEW
318
              should_break = process_event(&mut app, ev, &mut is_first_render).await;
×
NEW
319
              if should_break {
×
NEW
320
                break;
×
NEW
321
              }
×
322
            }
NEW
323
            None => break,
×
324
          }
325
        }
NEW
326
      }
×
327

NEW
328
      if should_break {
×
NEW
329
        break;
×
NEW
330
      }
×
331

332
      // Get the size of the screen on each loop to account for resize events
UNCOV
333
      if let Ok(size) = terminal.backend().size() {
×
UNCOV
334
        if app.refresh || app.size.as_size() != size {
×
335
          app.size.width = size.width;
×
336
          app.size.height = size.height;
×
337
        }
×
NEW
338
      }
×
339

340
      // Draw the UI layout AFTER processing events so the frame is up-to-date
341
      terminal.draw(|f| ui::draw(f, &mut app))?;
×
342

UNCOV
343
      is_first_render = false;
×
344
      let pending_shell_exec = app.take_pending_shell_exec();
×
345
      let should_quit = app.should_quit;
×
346
      (pending_shell_exec, should_quit)
×
347
    };
348

349
    if let Some(request) = pending_shell_exec {
×
350
      drop(events);
×
351
      execute_pending_shell_exec(app, &mut terminal, request).await?;
×
352
      events = event::Events::new(cli.tick_rate);
×
353
    }
×
354

355
    if should_quit {
×
356
      break;
×
357
    }
×
358
  }
359

360
  terminal.show_cursor()?;
×
361
  shutdown(terminal)?;
×
362
  Ok(())
×
363
}
×
364

365
// shutdown the CLI and show terminal
366
fn shutdown(mut terminal: Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
×
367
  info!("Shutting down");
×
368
  disable_raw_mode()?;
×
369
  execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
×
370
  terminal.show_cursor()?;
×
371
  Ok(())
×
372
}
×
373

374
fn suspend_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
×
375
  disable_raw_mode()?;
×
376
  execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
×
377
  terminal.show_cursor()?;
×
378
  Ok(())
×
379
}
×
380

381
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
×
382
  enable_raw_mode()?;
×
383
  execute!(terminal.backend_mut(), EnterAlternateScreen)?;
×
384
  terminal.hide_cursor()?;
×
385
  terminal.clear()?;
×
386
  Ok(())
×
387
}
×
388

389
trait ShellTerminal {
390
  fn suspend(&mut self) -> Result<()>;
391
  fn restore(&mut self) -> Result<()>;
392
}
393

394
impl ShellTerminal for Terminal<CrosstermBackend<Stdout>> {
395
  fn suspend(&mut self) -> Result<()> {
×
396
    suspend_terminal(self)
×
397
  }
×
398

399
  fn restore(&mut self) -> Result<()> {
×
400
    restore_terminal(self)
×
401
  }
×
402
}
403

404
async fn execute_pending_shell_exec(
×
405
  app: &Arc<Mutex<App>>,
×
406
  terminal: &mut Terminal<CrosstermBackend<Stdout>>,
×
407
  request: app::PendingShellExec,
×
408
) -> Result<()> {
×
409
  execute_pending_shell_exec_with(app, terminal, request, |request| {
×
410
    let target = ShellExecTarget {
×
411
      namespace: request.namespace,
×
412
      pod: request.pod,
×
413
      container: request.container,
×
414
    };
×
415
    let command = prepare_shell_exec(&target).map_err(|error| anyhow!(error.to_string()))?;
×
416
    let shell = command.shell.clone();
×
417
    run_shell_exec(&command).map_err(|error| anyhow!(error.to_string()))?;
×
418
    Ok(shell)
×
419
  })
×
420
  .await
×
421
}
×
422

423
async fn execute_pending_shell_exec_with<F, T>(
2✔
424
  app: &Arc<Mutex<App>>,
2✔
425
  terminal: &mut T,
2✔
426
  request: app::PendingShellExec,
2✔
427
  run_shell: F,
2✔
428
) -> Result<()>
2✔
429
where
2✔
430
  F: FnOnce(app::PendingShellExec) -> Result<String>,
2✔
431
  T: ShellTerminal,
2✔
432
{
2✔
433
  terminal.suspend()?;
2✔
434
  let shell_result = run_shell(request.clone());
2✔
435
  let restore_result = terminal.restore();
2✔
436

437
  let mut app = app.lock().await;
2✔
438

439
  if let Err(error) = restore_result {
2✔
440
    app.handle_error(anyhow!(
×
441
      "Unable to restore terminal after shell exec: {}",
×
442
      error
×
443
    ));
×
444
    return Err(error);
×
445
  }
2✔
446

447
  match shell_result {
2✔
448
    Ok(shell) => {
1✔
449
      app.set_status_message(format!(
1✔
450
        "Closed {} shell for {}/{}",
1✔
451
        shell, request.pod, request.container
1✔
452
      ));
1✔
453
      Ok(())
1✔
454
    }
455
    Err(error) => {
1✔
456
      app.handle_error(anyhow!(
1✔
457
        "Unable to open shell for {}/{}: {}",
1✔
458
        request.pod,
1✔
459
        request.container,
1✔
460
        error
1✔
461
      ));
1✔
462
      Ok(())
1✔
463
    }
464
  }
465
}
2✔
466

467
fn setup_logging(debug: Option<String>) -> Result<(), SetLoggerError> {
×
468
  let log_file = format!(
×
469
    "./kdash-debug-{}.log",
470
    chrono::Local::now().format("%Y%m%d%H%M%S")
×
471
  );
472
  let log_level = debug
×
473
    .map(|level| match level.to_lowercase().as_str() {
×
474
      "debug" => LevelFilter::Debug,
×
475
      "trace" => LevelFilter::Trace,
×
476
      "warn" => LevelFilter::Warn,
×
477
      "error" => LevelFilter::Error,
×
478
      _ => LevelFilter::Info,
×
479
    })
×
480
    .unwrap_or_else(|| LevelFilter::Info);
×
481

482
  WriteLogger::init(
×
483
    log_level,
×
484
    Config::default(),
×
485
    File::create(log_file).unwrap(),
×
486
  )
487
}
×
488

489
#[cfg(debug_assertions)]
490
fn panic_hook(info: &PanicHookInfo<'_>) {
×
491
  use backtrace::Backtrace;
492
  use crossterm::style::Print;
493

494
  let (msg, location) = get_panic_info(info);
×
495

496
  let stacktrace: String = format!("{:?}", Backtrace::new()).replace('\n', "\n\r");
×
497

498
  disable_raw_mode().unwrap();
×
499
  execute!(
×
500
    io::stdout(),
×
501
    LeaveAlternateScreen,
502
    Print(format!(
503
      "thread '<unnamed>' panicked at '{}', {}\n\r{}",
504
      msg, location, stacktrace
505
    )),
506
  )
507
  .unwrap();
×
508
}
×
509

510
#[cfg(not(debug_assertions))]
511
fn panic_hook(info: &PanicHookInfo<'_>) {
512
  use backtrace::Backtrace;
513
  use crossterm::style::Print;
514
  use human_panic::{handle_dump, print_msg, Metadata};
515
  use log::error;
516

517
  let meta = Metadata::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
518
    .authors(env!("CARGO_PKG_AUTHORS").replace(':', ", "))
519
    .homepage(env!("CARGO_PKG_HOMEPAGE"));
520

521
  let file_path = handle_dump(&meta, info);
522
  let (msg, location) = get_panic_info(info);
523
  let stacktrace: String = format!("{:?}", Backtrace::new()).replace('\n', "\n\r");
524

525
  error!(
526
    "thread '<unnamed>' panicked at '{}', {}\n\r{}",
527
    msg, location, stacktrace
528
  );
529

530
  disable_raw_mode().unwrap();
531
  execute!(
532
    io::stdout(),
533
    LeaveAlternateScreen,
534
    Print(format!("Error: '{}' at {}\n", msg, location)),
535
  )
536
  .unwrap();
537
  print_msg(file_path, &meta).expect("human-panic: printing error message to console failed");
538
}
539

540
fn get_panic_info(info: &PanicHookInfo<'_>) -> (String, String) {
×
541
  let location = info.location().unwrap();
×
542

543
  let msg = match info.payload().downcast_ref::<&'static str>() {
×
544
    Some(s) => *s,
×
545
    None => match info.payload().downcast_ref::<String>() {
×
546
      Some(s) => &s[..],
×
547
      None => "Box<Any>",
×
548
    },
549
  };
550

551
  (msg.to_string(), format!("{}", location))
×
552
}
×
553

554
#[cfg(test)]
555
mod tests {
556
  use super::{execute_pending_shell_exec_with, resolve_log_tail_lines};
557
  use crate::{app::App, config::KdashConfig};
558
  use anyhow::anyhow;
559
  use std::sync::Arc;
560
  use tokio::sync::Mutex;
561

562
  struct StubTerminal;
563

564
  impl super::ShellTerminal for StubTerminal {
565
    fn suspend(&mut self) -> anyhow::Result<()> {
2✔
566
      Ok(())
2✔
567
    }
2✔
568

569
    fn restore(&mut self) -> anyhow::Result<()> {
2✔
570
      Ok(())
2✔
571
    }
2✔
572
  }
573

574
  #[test]
575
  fn test_resolve_log_tail_lines_uses_default() {
1✔
576
    assert_eq!(resolve_log_tail_lines(None, &KdashConfig::default()), 100);
1✔
577
  }
1✔
578

579
  #[test]
580
  fn test_resolve_log_tail_lines_uses_config_when_cli_missing() {
1✔
581
    let config = KdashConfig {
1✔
582
      log_tail_lines: Some(250),
1✔
583
      ..KdashConfig::default()
1✔
584
    };
1✔
585

586
    assert_eq!(resolve_log_tail_lines(None, &config), 250);
1✔
587
  }
1✔
588

589
  #[test]
590
  fn test_resolve_log_tail_lines_prefers_cli() {
1✔
591
    let config = KdashConfig {
1✔
592
      log_tail_lines: Some(250),
1✔
593
      ..KdashConfig::default()
1✔
594
    };
1✔
595

596
    assert_eq!(resolve_log_tail_lines(Some(500), &config), 500);
1✔
597
  }
1✔
598

599
  #[tokio::test]
600
  async fn test_execute_pending_shell_exec_with_sets_success_status_and_clears_request() {
1✔
601
    let app = Arc::new(Mutex::new(App::default()));
1✔
602
    let mut terminal = StubTerminal;
1✔
603

604
    let result = execute_pending_shell_exec_with(
1✔
605
      &app,
1✔
606
      &mut terminal,
1✔
607
      crate::app::PendingShellExec {
1✔
608
        namespace: "default".into(),
1✔
609
        pod: "api-123".into(),
1✔
610
        container: "web".into(),
1✔
611
      },
1✔
612
      |_| Ok("/bin/sh".into()),
1✔
613
    )
614
    .await;
1✔
615

616
    assert!(result.is_ok());
1✔
617

618
    let app = app.lock().await;
1✔
619
    assert!(app.api_error.is_empty());
1✔
620
    assert_eq!(
1✔
621
      app.status_message.text(),
1✔
622
      "Closed /bin/sh shell for api-123/web"
623
    );
624
    assert!(app.pending_shell_exec().is_none());
1✔
625
  }
1✔
626

627
  #[tokio::test]
628
  async fn test_execute_pending_shell_exec_with_reports_shell_errors_after_restoring_terminal() {
1✔
629
    let app = Arc::new(Mutex::new(App::default()));
1✔
630
    let mut terminal = StubTerminal;
1✔
631

632
    let result = execute_pending_shell_exec_with(
1✔
633
      &app,
1✔
634
      &mut terminal,
1✔
635
      crate::app::PendingShellExec {
1✔
636
        namespace: "default".into(),
1✔
637
        pod: "api-123".into(),
1✔
638
        container: "web".into(),
1✔
639
      },
1✔
640
      |_| Err(anyhow!("probe failed")),
1✔
641
    )
642
    .await;
1✔
643

644
    assert!(result.is_ok());
1✔
645

646
    let app = app.lock().await;
1✔
647
    assert_eq!(
1✔
648
      app.api_error,
1✔
649
      "Unable to open shell for api-123/web: probe failed"
650
    );
651
    assert!(app.status_message.is_empty());
1✔
652
  }
1✔
653
}
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