use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tmprl_client::{Codec, Conn, NamespaceInfo};
use tmprl_core::ScheduleRow;
use tmprl_core::form::Form;
use tmprl_core::history::{NormalizedEvent, group_events, merge_events};
use tmprl_core::jumplist::Jumplist;
use tmprl_core::mutation::{Confirm, Mutation};
use tmprl_core::outline::{Outline, Row};
use tmprl_core::payload::Payload;
use tmprl_core::picker::{self, Picker, Target};
use tmprl_core::search::{self, Search};
use tmprl_core::timerange::parse_backfill;
use tmprl_core::{
Action, Chord, Keymap, Loadable, Mode, PayloadPart, Pending, PendingEntry, Registry,
Resolution, SavedView, StatusCounts, WorkflowList, WorkflowRow, WorkflowStatus, default_keymap,
};
use tokio::sync::mpsc::UnboundedSender;
use crate::view::View;
use tmprl_ui::{Axis, Direction, Rect as UiRect, Tabs, ViewId};
const PAGE_SIZE: i32 = 50;
const HISTORY_PAGE_SIZE: i32 = 500;
use tmprl_client::Continuation as Tokens;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
Namespaces,
Workflows,
History,
Schedules,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MutationKind {
PauseSchedule,
TriggerSchedule,
DeleteSchedule,
BackfillSchedule,
Cancel,
Terminate,
Signal,
Delete,
Reset,
Update,
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[derive(Debug, Clone, PartialEq)]
pub struct Jump {
pub screen: Screen,
pub scope: Vec<String>,
pub query: String,
pub viewing: Option<WorkflowRow>,
pub cursor: usize,
pub cursor_key: Option<(String, String)>,
}
fn create_private_dir(path: &std::path::Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new().mode(0o700).create(path)
}
#[cfg(not(unix))]
{
std::fs::create_dir(path)
}
}
fn write_private_file(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options.open(path)?.write_all(bytes)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditRequest {
pub path: std::path::PathBuf,
pub dir: std::path::PathBuf,
pub what: String,
}
fn describe_pane(v: &View) -> String {
match v.screen {
Screen::Namespaces => "namespaces".to_string(),
Screen::Workflows => {
let scope = v.scope.join(", ");
if v.query.trim().is_empty() {
format!("workflows {scope}")
} else {
format!("workflows {scope} [{}]", v.query.trim())
}
}
Screen::History => match &v.viewing {
Some(w) => format!("history {} {}", w.workflow_id, w.workflow_type),
None => "history".to_string(),
},
Screen::Schedules => format!("schedules {}", v.scope.join(", ")),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptKind {
Command,
Pipe,
Search,
Signal,
Update,
Backfill,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Prompt {
pub kind: PromptKind,
pub buf: String,
}
impl Prompt {
pub fn sigil(&self) -> &'static str {
match self.kind {
PromptKind::Command => ":",
PromptKind::Pipe => "!",
PromptKind::Search => "/",
PromptKind::Signal => "signal:",
PromptKind::Update => "update:",
PromptKind::Backfill => "backfill:",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecodeState {
NoCodec,
InFlight,
Idle,
Failed(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InsertTarget {
Scratch,
Query,
}
#[derive(Debug)]
pub enum Msg {
Key(Chord),
Tick,
Redraw,
Quit,
Namespaces(Result<Vec<NamespaceInfo>, String>),
Workflows {
generation: u64,
append: bool,
result: Result<(Vec<WorkflowRow>, Tokens), String>,
},
Counts {
generation: u64,
result: Result<StatusCounts, String>,
},
Schedules {
generation: u64,
result: Result<Vec<ScheduleRow>, String>,
},
Piped(Result<String, String>),
Mutated {
mutation: Box<Mutation>,
result: Result<(), String>,
batch: Option<(usize, usize)>,
},
Decoded(Result<Vec<(u64, Payload)>, String>),
History {
generation: u64,
result: Result<(Vec<NormalizedEvent>, Vec<u8>), String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Note {
Info,
Warn,
Error,
}
pub struct App {
pub view: View,
parked: std::collections::HashMap<ViewId, View>,
pub tabs: Tabs,
next_view_id: u64,
frame: UiRect,
pub mode: Mode,
pub pending: Pending,
pub registry: Registry,
pub keymap: Keymap,
decoded: HashMap<u64, Payload>,
decoding: HashSet<u64>,
decode_failed: HashMap<u64, String>,
codec: Option<Arc<Codec>>,
pub views: Vec<SavedView>,
pub which_key: Vec<PendingEntry>,
pub show_help: bool,
pub help_scroll: usize,
pub help_max_scroll: usize,
pub prompt: Option<Prompt>,
pub confirm: Option<Confirm>,
pub form: Option<Form>,
pub insert_buf: String,
pub insert_target: InsertTarget,
pub picker: Option<Picker>,
pub editing: Option<EditRequest>,
pub jumps: Jumplist<Jump>,
pub search: Search,
pub note: Option<(String, Note)>,
pub should_quit: bool,
pub dirty: bool,
profile: String,
address: String,
accent: Option<tmprl_core::config::Accent>,
readonly: bool,
namespace: String,
conn: Option<Arc<Conn>>,
tx: UnboundedSender<Msg>,
}
impl App {
pub fn new(conn: Conn, tx: UnboundedSender<Msg>) -> Self {
let (profile, address, namespace) = (
conn.profile().to_string(),
conn.address().to_string(),
conn.namespace().to_string(),
);
Self::build(Some(Arc::new(conn)), profile, address, namespace, tx)
}
#[cfg(test)]
pub fn detached(profile: &str, namespace: &str, tx: UnboundedSender<Msg>) -> Self {
Self::build(
None,
profile.to_string(),
"http://detached".to_string(),
namespace.to_string(),
tx,
)
}
fn build(
conn: Option<Arc<Conn>>,
profile: String,
address: String,
namespace: String,
tx: UnboundedSender<Msg>,
) -> Self {
Self {
view: View::new(&namespace),
parked: std::collections::HashMap::new(),
tabs: Tabs::new(ViewId(0)),
next_view_id: 1,
frame: UiRect::new(0, 0, 80, 24),
mode: Mode::Normal,
pending: Pending::default(),
registry: Registry::builtin(),
keymap: default_keymap(),
decoded: HashMap::new(),
decoding: HashSet::new(),
decode_failed: HashMap::new(),
codec: None,
views: Vec::new(),
which_key: Vec::new(),
show_help: false,
help_scroll: 0,
help_max_scroll: 0,
prompt: None,
confirm: None,
form: None,
insert_buf: String::new(),
insert_target: InsertTarget::Scratch,
picker: None,
editing: None,
jumps: Jumplist::default(),
search: Search::default(),
note: None,
should_quit: false,
dirty: true,
profile,
address,
accent: None,
readonly: false,
namespace,
conn,
tx,
}
}
pub fn apply_config(&mut self, keys: Option<&str>, views: Option<&str>, config: Option<&str>) {
if let Some(src) = config {
match tmprl_core::config::parse_config(src) {
Ok(cfg) => {
let resolved = cfg.resolve(&self.profile);
self.codec = resolved
.codec
.map(|c| Arc::new(Codec::new(c.endpoint, c.auth)));
self.accent = resolved.accent;
self.readonly = resolved.readonly;
}
Err(e) => self.note = Some((e.to_string(), Note::Error)),
}
}
if let Some(src) = views {
match tmprl_core::config::parse_views(src) {
Ok(v) => {
self.registry.add_views(&v);
if let Err(e) = tmprl_core::config::bind_views(&v, &mut self.keymap) {
self.note = Some((e.to_string(), Note::Error));
}
self.views = v;
}
Err(e) => self.note = Some((e.to_string(), Note::Error)),
}
}
if let Some(src) = keys
&& let Err(e) = tmprl_core::config::apply_keys(src, &self.registry, &mut self.keymap)
{
self.note = Some((e.to_string(), Note::Error));
}
}
pub fn profile(&self) -> &str {
&self.profile
}
pub fn namespace(&self) -> &str {
&self.namespace
}
pub fn namespace_rows(&self) -> &[NamespaceInfo] {
self.view
.namespaces
.value()
.map(Vec::as_slice)
.unwrap_or(&[])
}
pub fn workflow_rows(&self) -> &[WorkflowRow] {
self.view
.workflows
.value()
.map(WorkflowList::rows)
.unwrap_or(&[])
}
pub fn row_count(&self) -> usize {
self.view.row_count()
}
pub fn is_editing_query(&self) -> bool {
self.mode == Mode::Insert && self.insert_target == InsertTarget::Query
}
pub fn query_display(&self) -> &str {
if self.is_editing_query() {
&self.insert_buf
} else {
&self.view.query
}
}
pub fn selection(&self) -> Option<(usize, usize)> {
self.view.selection()
}
pub fn handle(&mut self, msg: Msg) {
self.dirty = true;
match msg {
Msg::Key(chord) => self.on_key(chord),
Msg::Quit => self.should_quit = true,
Msg::Tick | Msg::Redraw => {}
Msg::Mutated {
mutation,
result,
batch,
} => {
let outcome = match &result {
Ok(()) => "ok".to_string(),
Err(e) => format!("failed: {e}"),
};
self.audit(&mutation, &outcome);
match result {
Ok(()) => {
self.note = Some((
match batch {
Some((done, total)) => {
format!("{} {done}/{total}", mutation.past_tense())
}
None => {
format!("{} {}", mutation.past_tense(), mutation.workflow_id())
}
},
Note::Info,
));
if let Mutation::PauseSchedule {
schedule_id,
paused,
..
} = mutation.as_ref()
&& let Some(rows) = self.view.schedules.value_mut()
&& let Some(row) =
rows.iter_mut().find(|r| &r.schedule_id == schedule_id)
{
row.paused = *paused;
}
self.refresh();
}
Err(e) => self.note = Some((e, Note::Error)),
}
}
Msg::Piped(result) => {
self.view.piped = Some(result);
self.view.detail_scroll = 0;
}
Msg::Decoded(Ok(pairs)) => {
for (key, payload) in pairs {
self.decoding.remove(&key);
self.decoded.insert(key, payload);
}
self.apply_decoded();
}
Msg::Decoded(Err(e)) => {
for key in std::mem::take(&mut self.decoding) {
self.decode_failed.insert(key, e.clone());
}
self.note = Some((e, Note::Error));
}
Msg::Namespaces(Ok(list)) => {
self.view.namespaces = Loadable::loaded(list);
self.clamp_cursor();
}
Msg::Namespaces(Err(e)) => {
if is_permission_denied(&e) {
self.view.namespaces = Loadable::loaded(vec![NamespaceInfo {
name: self.namespace.clone(),
state: "Registered".into(),
retention_days: 0,
description: "from the profile; this key cannot list namespaces".into(),
}]);
self.clamp_cursor();
self.note = Some((
format!(
"this key cannot list namespaces, showing {} from the profile",
self.namespace
),
Note::Info,
));
} else {
self.note = Some((e.clone(), Note::Error));
self.view.namespaces = Loadable::Failed(e);
}
}
Msg::Workflows {
generation,
append,
result,
} => {
if generation != self.view.generation {
return; }
self.view.loading_more = false;
match result {
Ok((rows, tokens)) => {
match (append, self.view.workflows.value_mut()) {
(true, Some(list)) => list.append(rows, tokens),
_ => {
let mut list = WorkflowList::default();
list.reset(rows, tokens);
self.view.workflows = Loadable::loaded(list);
}
}
self.apply_decoded();
self.restore_cursor();
}
Err(e) => {
self.note = Some((e.clone(), Note::Error));
if !append {
self.view.workflows = Loadable::Failed(e);
}
}
}
}
Msg::History { generation, result } => {
if generation != self.view.generation {
return;
}
self.view.loading_more = false;
match result {
Ok((events, token)) => {
if !token.is_empty() {
self.view.history_resume = token.clone();
} else if self.view.following {
self.stop_following();
self.note =
Some(("workflow closed, follow stopped".into(), Note::Info));
}
self.view.history_token = token;
merge_events(&mut self.view.history_events, events);
let groups = group_events(&self.view.history_events);
let events = self.view.history_events.clone();
match self.view.history.value_mut() {
Some(outline) => outline.replace(events, groups),
None => {
self.view.history = Loadable::loaded(Outline::new(events, groups))
}
}
self.clamp_cursor();
}
Err(e) => {
self.note = Some((e.clone(), Note::Error));
if self.view.history_events.is_empty() {
self.view.history = Loadable::Failed(e);
}
}
}
}
Msg::Schedules { generation, result } => {
if generation != self.view.generation {
return;
}
self.view.loading_more = false;
self.view.schedules = match result {
Ok(rows) => Loadable::loaded(rows),
Err(e) => {
self.note = Some((e.clone(), Note::Error));
Loadable::Failed(e)
}
};
self.clamp_cursor();
}
Msg::Counts { generation, result } => {
if generation != self.view.generation {
return;
}
self.view.counts = match result {
Ok(c) => Loadable::loaded(c),
Err(e) => Loadable::Failed(e),
};
}
}
}
fn on_key(&mut self, chord: Chord) {
if self.confirm.is_some() {
self.confirm_key(chord);
return;
}
if self.prompt.is_some() {
self.prompt_key(chord);
return;
}
if self.picker.is_some() {
self.picker_key(chord);
return;
}
if self.form.is_some() {
self.form_key(chord);
return;
}
self.note = None;
match self.keymap.resolve(self.mode, &mut self.pending, chord) {
Resolution::Count(_) => {
self.which_key.clear();
}
Resolution::Pending { candidates } => {
self.which_key = candidates;
}
Resolution::Run { id, count } => {
self.which_key.clear();
self.run(id, count);
}
Resolution::Unbound { flushed } => {
self.which_key.clear();
if self.mode == Mode::Insert {
self.insert_keys(flushed);
}
}
}
}
pub fn run(&mut self, id: &str, count: Option<u32>) {
let Some(cmd) = self.registry.get(id) else {
self.note = Some((format!("no such command: {id}"), Note::Error));
return;
};
let n = count.unwrap_or(1) as usize;
match cmd.action {
Action::Quit => self.should_quit = true,
Action::ToggleHelp => {
self.show_help = !self.show_help;
self.help_scroll = 0;
}
Action::OpenCommandLine => {
self.prompt = Some(Prompt {
kind: PromptKind::Command,
buf: String::new(),
});
self.mode = Mode::Command;
}
Action::Cancel => {
if self.show_help {
self.show_help = false;
self.help_scroll = 0;
} else {
self.view.anchor = None;
self.mode = Mode::Normal;
self.pending.clear();
self.which_key.clear();
}
}
Action::Refresh => self.refresh(),
Action::MoveDown if self.show_help => self.scroll_help(n as isize),
Action::MoveUp if self.show_help => self.scroll_help(-(n as isize)),
Action::MoveTop if self.show_help => self.help_scroll = 0,
Action::MoveBottom if self.show_help => self.help_scroll = self.help_max_scroll,
Action::HalfPageDown if self.show_help => {
self.scroll_help((self.view.page / 2).max(1) as isize)
}
Action::HalfPageUp if self.show_help => {
self.scroll_help(-((self.view.page / 2).max(1) as isize))
}
Action::MoveDown => self.move_cursor(n as isize),
Action::MoveUp => self.move_cursor(-(n as isize)),
Action::MoveTop => {
self.mark_jump();
self.set_cursor(0)
}
Action::MoveBottom => {
self.mark_jump();
self.set_cursor(self.row_count().saturating_sub(1))
}
Action::HalfPageDown => self.move_cursor((self.view.page / 2).max(1) as isize),
Action::HalfPageUp => self.move_cursor(-((self.view.page / 2).max(1) as isize)),
Action::OpenItem => self.open_focused(),
Action::GoUp => self.go_up(),
Action::GoSchedules => self.go_to(Screen::Schedules),
Action::GoWorkflows => self.go_to(Screen::Workflows),
Action::JumpBack => self.jump(true),
Action::JumpForward => self.jump(false),
Action::PauseSchedule => self.confirm_mutation(MutationKind::PauseSchedule),
Action::TriggerSchedule => self.confirm_mutation(MutationKind::TriggerSchedule),
Action::DeleteSchedule => self.confirm_mutation(MutationKind::DeleteSchedule),
Action::BackfillSchedule => self.confirm_mutation(MutationKind::BackfillSchedule),
Action::CreateSchedule => self.open_new_schedule_form(),
Action::EnterInsert => {
self.mode = Mode::Insert;
if self.view.screen == Screen::Workflows {
self.insert_target = InsertTarget::Query;
self.insert_buf = self.view.query.clone();
} else {
self.insert_target = InsertTarget::Scratch;
self.insert_buf.clear();
}
}
Action::LeaveInsert => {
self.mode = Mode::Normal;
self.insert_target = InsertTarget::Scratch;
self.insert_buf.clear();
}
Action::EnterVisual => {
self.mode = Mode::Visual;
self.view.anchor = Some(self.view.cursor);
}
Action::EnterVisualLine => {
self.mode = Mode::VisualLine;
self.view.anchor = Some(self.view.cursor);
}
Action::YankField => self.yank(self.field_under_cursor()),
Action::YankRecord => self.yank(self.records_selected()),
Action::YankPayloadAll => self.yank_payload(PayloadPart::All),
Action::YankPayloadInput => self.yank_payload(PayloadPart::Input),
Action::YankPayloadResult => self.yank_payload(PayloadPart::Result),
Action::LoadMore => self.load_more(),
Action::SelectView(key) => self.select_view(key),
Action::ToggleFold => self.toggle_fold(),
Action::ExpandAll => self.with_outline(|o| o.expand_all()),
Action::CollapseAll => self.with_outline(|o| o.collapse_all()),
Action::TogglePlumbing => {
let showing = self
.view
.history
.value()
.is_some_and(Outline::show_plumbing);
self.with_outline(|o| o.set_show_plumbing(!showing));
self.note = Some((
if showing {
"workflow tasks hidden".into()
} else {
"workflow tasks shown".into()
},
Note::Info,
));
}
Action::OpenSearch => self.open_search(),
Action::FindWorkflow => self.open_picker(picker::Kind::Workflows),
Action::FindEvent => self.open_picker(picker::Kind::HistoryRows),
Action::FindPane => self.open_picker(picker::Kind::Panes),
Action::FindCommand => self.open_picker(picker::Kind::Commands),
Action::FindFilter => self.open_picker(picker::Kind::Filters),
Action::FindNamespace => self.open_picker(picker::Kind::Namespaces),
Action::ProblemList => self.show_problems(),
Action::SearchNext => self.jump_match(true),
Action::SearchPrev => self.jump_match(false),
Action::NextFailure => self.jump_failure(true),
Action::PrevFailure => self.jump_failure(false),
Action::ToggleFollow => self.toggle_follow(),
Action::DetailDown => self.scroll_detail(n as isize),
Action::DetailUp => self.scroll_detail(-(n as isize)),
Action::OpenPipe => self.open_pipe(),
Action::OpenEditor => self.open_editor(),
Action::CancelWorkflow => self.confirm_mutation(MutationKind::Cancel),
Action::TerminateWorkflow => self.confirm_mutation(MutationKind::Terminate),
Action::SignalWorkflow => self.confirm_mutation(MutationKind::Signal),
Action::DeleteWorkflow => self.confirm_mutation(MutationKind::Delete),
Action::ResetWorkflow => self.confirm_mutation(MutationKind::Reset),
Action::UpdateWorkflow => self.confirm_mutation(MutationKind::Update),
Action::SplitRight => self.split(Axis::Columns),
Action::SplitDown => self.split(Axis::Rows),
Action::CloseWindow => self.close_window(),
Action::EqualizeWindows => self.tabs.current_mut().equalize(),
Action::FocusLeft => self.focus_window(Direction::Left),
Action::FocusRight => self.focus_window(Direction::Right),
Action::FocusUp => self.focus_window(Direction::Up),
Action::FocusDown => self.focus_window(Direction::Down),
Action::GrowLeft => self.resize_window(Direction::Left),
Action::GrowRight => self.resize_window(Direction::Right),
Action::GrowUp => self.resize_window(Direction::Up),
Action::GrowDown => self.resize_window(Direction::Down),
Action::NewTab => self.new_tab(),
Action::CloseTab => self.close_tab(),
Action::NextTab => self.switch_tab(true),
Action::PrevTab => self.switch_tab(false),
Action::ToggleDetail => {
if self.view.screen == Screen::History {
self.view.show_detail = !self.view.show_detail;
self.view.detail_scroll = 0;
self.view.piped = None;
self.maybe_decode();
} else {
self.note = Some((
"payloads are shown on a workflow history".into(),
Note::Warn,
));
}
}
}
self.clamp_cursor();
}
fn open_focused(&mut self) {
match self.view.screen {
Screen::Namespaces => {
let (lo, hi) = self
.selection()
.unwrap_or((self.view.cursor, self.view.cursor));
let scope: Vec<String> = self
.namespace_rows()
.iter()
.skip(lo)
.take(hi.saturating_sub(lo) + 1)
.map(|n| n.name.clone())
.collect();
if scope.is_empty() {
self.note = Some(("nothing to open".into(), Note::Warn));
return;
}
self.mark_jump();
self.view.namespace_cursor = self.view.cursor;
self.view.anchor = None;
self.mode = Mode::Normal;
self.view.screen = Screen::Workflows;
self.view.scope = scope;
self.view.cursor = 0;
self.view.cursor_key = None;
self.load_workflows(false);
}
Screen::Workflows => {
let Some(row) = self.workflow_rows().get(self.view.cursor).cloned() else {
self.note = Some(("nothing to open".into(), Note::Warn));
return;
};
self.mark_jump();
self.view.workflow_cursor = self.view.cursor;
self.view.anchor = None;
self.mode = Mode::Normal;
self.view.screen = Screen::History;
self.view.viewing = Some(row);
self.view.cursor = 0;
self.load_history();
}
Screen::History => self.toggle_fold(),
Screen::Schedules => {
self.note = Some((
"a schedule has no detail view; gw for its workflows".into(),
Note::Warn,
));
}
}
}
fn go_up(&mut self) {
match self.view.screen {
Screen::History => {
self.mark_jump();
self.view.screen = Screen::Workflows;
self.view.cursor = self.view.workflow_cursor;
self.view.viewing = None;
self.reset_history();
self.restore_cursor();
}
Screen::Workflows => {
self.mark_jump();
self.view.screen = Screen::Namespaces;
self.view.cursor = self.view.namespace_cursor;
self.view.anchor = None;
self.clamp_cursor();
}
Screen::Schedules => {
self.mark_jump();
self.view.screen = Screen::Namespaces;
self.view.cursor = self.view.namespace_cursor;
self.view.anchor = None;
self.clamp_cursor();
}
Screen::Namespaces => {
self.note = Some(("already at the top level".into(), Note::Warn));
}
}
}
fn group_under_cursor(&self) -> Option<usize> {
match self.view.history.value()?.row_at(self.view.cursor)? {
Row::Group { group, .. } | Row::Event { group, .. } => Some(group),
}
}
fn toggle_fold(&mut self) {
let Some(group) = self.group_under_cursor() else {
return;
};
if let Some(outline) = self.view.history.value_mut()
&& let Some(row) = outline.toggle(group)
{
self.view.cursor = row;
}
self.clamp_cursor();
}
fn with_outline(&mut self, f: impl FnOnce(&mut Outline)) {
let was = self.group_under_cursor();
let Some(outline) = self.view.history.value_mut() else {
return;
};
f(outline);
self.view.cursor = was
.and_then(|g| outline.row_of_group(g))
.unwrap_or(self.view.cursor);
self.clamp_cursor();
}
fn scroll_detail(&mut self, delta: isize) {
let next = (self.view.detail_scroll as isize + delta)
.clamp(0, self.view.detail_max_scroll as isize);
self.view.detail_scroll = next as usize;
}
fn toggle_follow(&mut self) {
if self.view.screen != Screen::History {
self.note = Some(("follow applies to a workflow history".into(), Note::Warn));
return;
}
if self.view.following {
self.stop_following();
self.note = Some(("follow stopped".into(), Note::Info));
return;
}
if self.view.history_token.is_empty() && self.workflow_is_closed() {
self.note = Some((
"this workflow has closed, nothing to follow".into(),
Note::Warn,
));
return;
}
self.start_following();
}
fn workflow_is_closed(&self) -> bool {
self.view
.history
.value()
.map(|o| tmprl_core::outline::summarize(o.groups()))
.is_some_and(|s| s.outcome != tmprl_core::history::Outcome::Pending)
}
fn start_following(&mut self) {
let Some(row) = self.view.viewing.clone() else {
return;
};
self.view.following = true;
self.note = Some(("following, F to stop".into(), Note::Info));
let Some(conn) = self.conn.clone() else {
return;
};
let (tx, generation) = (self.tx.clone(), self.view.generation);
let mut token = self.view.history_resume.clone();
self.view.follow_task = Some(tokio::spawn(async move {
loop {
let result = conn
.follow_history(&row.namespace, &row.workflow_id, &row.run_id, token.clone())
.await;
match result {
Ok(page) => {
let done = page.next_page_token.is_empty();
token = page.next_page_token.clone();
if tx
.send(Msg::History {
generation,
result: Ok((page.events, page.next_page_token)),
})
.is_err()
{
return; }
if done {
return; }
}
Err(e) => {
let _ = tx.send(Msg::History {
generation,
result: Err(e.to_string()),
});
return;
}
}
}
}));
}
fn stop_following(&mut self) {
self.view.stop_following();
}
fn open_picker(&mut self, kind: picker::Kind) {
let items = match kind {
picker::Kind::Workflows => self.workflow_items(),
picker::Kind::HistoryRows => self.history_items(),
picker::Kind::Panes => self.pane_items(),
picker::Kind::Commands => self.command_items(),
picker::Kind::Filters => self.filter_items(),
picker::Kind::Namespaces => self.namespace_items(),
};
if items.is_empty() {
self.note = Some((
match kind {
picker::Kind::Workflows => "no workflows loaded; open a namespace first",
picker::Kind::HistoryRows => "no history here; open a workflow first",
picker::Kind::Panes => "only this pane is open",
picker::Kind::Commands => "no commands",
picker::Kind::Filters => "nothing to filter on yet",
picker::Kind::Namespaces => "no namespaces loaded yet",
}
.into(),
Note::Warn,
));
return;
}
self.picker = Some(Picker::new(kind, items));
}
fn workflow_items(&self) -> Vec<picker::Item> {
self.view
.workflow_rows()
.iter()
.map(|w| {
picker::Item::new(
w.workflow_id.clone(),
Target::Workflow {
namespace: w.namespace.clone(),
run_id: w.run_id.clone(),
},
)
.with_note(format!("{} {}", w.workflow_type, w.status.query_name()))
.with_preview(format!(
"workflow id {}\nrun id {}\ntype {}\ntask queue {}\nnamespace {}\nstatus {}\nevents {}",
w.workflow_id,
w.run_id,
w.workflow_type,
w.task_queue,
w.namespace,
w.status.query_name(),
w.history_length,
))
})
.collect()
}
fn history_items(&self) -> Vec<picker::Item> {
if self.view.screen != Screen::History {
return Vec::new();
}
let labels = self.view.search_labels();
let Some(outline) = self.view.history.value() else {
return Vec::new();
};
labels
.into_iter()
.enumerate()
.map(|(row, label)| {
let note = match outline.row_at(row) {
Some(Row::Group { group, .. }) => outline
.group(group)
.map(|g| g.outcome.label().to_string())
.unwrap_or_default(),
Some(Row::Event { event, .. }) => outline
.event(event)
.map(|e| format!("event {}", e.id))
.unwrap_or_default(),
None => String::new(),
};
picker::Item::new(label, Target::Row(row)).with_note(note)
})
.collect()
}
fn pane_items(&self) -> Vec<picker::Item> {
let ids = self.tabs.views();
if ids.len() < 2 {
return Vec::new();
}
let focused = self.tabs.current().focused();
ids.into_iter()
.map(|id| {
let view = if id == focused {
Some(&self.view)
} else {
self.parked_view(id)
};
let label = match view {
Some(v) => describe_pane(v),
None => format!("pane {}", id.0),
};
picker::Item::new(label, Target::Pane(id.0)).with_note(if id == focused {
"current".to_string()
} else {
String::new()
})
})
.collect()
}
fn namespace_items(&self) -> Vec<picker::Item> {
self.view
.namespace_rows()
.iter()
.map(|n| {
picker::Item::new(n.name.clone(), Target::Namespace(n.name.clone()))
.with_note(n.state.clone())
.with_preview(format!(
"namespace {}\nstate {}\nretention {} days\n\n{}",
n.name, n.state, n.retention_days, n.description,
))
})
.collect()
}
fn command_items(&self) -> Vec<picker::Item> {
self.registry
.search("")
.into_iter()
.map(|c| {
picker::Item::new(
format!("{} {}", c.id, c.title),
Target::Command(c.id.to_string()),
)
.with_note(c.group)
})
.collect()
}
fn filter_items(&self) -> Vec<picker::Item> {
let mut items: Vec<picker::Item> = WorkflowStatus::DISPLAY_ORDER
.iter()
.map(|s| {
let clause = format!("ExecutionStatus = '{}'", s.query_name());
picker::Item::new(clause.clone(), Target::Query(clause)).with_note("status")
})
.collect();
let rows = self.view.workflow_rows();
let mut types: Vec<&str> = rows.iter().map(|w| w.workflow_type.as_str()).collect();
types.sort_unstable();
types.dedup();
for t in types {
let clause = format!("WorkflowType = '{t}'");
items.push(picker::Item::new(clause.clone(), Target::Query(clause)).with_note("type"));
}
let mut queues: Vec<&str> = rows.iter().map(|w| w.task_queue.as_str()).collect();
queues.sort_unstable();
queues.dedup();
for q in queues {
let clause = format!("TaskQueue = '{q}'");
items.push(
picker::Item::new(clause.clone(), Target::Query(clause)).with_note("task queue"),
);
}
for clause in ["ORDER BY StartTime DESC", "ORDER BY StartTime ASC"] {
items.push(
picker::Item::new(clause, Target::Query(clause.to_string())).with_note("order"),
);
}
items
}
fn picker_key(&mut self, chord: Chord) {
use tmprl_core::Key;
let Some(p) = self.picker.as_mut() else {
return;
};
let ctrl = chord.mods.ctrl;
match chord.key {
Key::Esc => self.picker = None,
Key::Enter => self.accept_picker(),
Key::Down => self.move_picker(1),
Key::Up => self.move_picker(-1),
Key::Char('n') if ctrl => self.move_picker(1),
Key::Char('p') if ctrl => self.move_picker(-1),
Key::Backspace if !p.backspace() => self.picker = None,
Key::Backspace => {}
Key::Char(c) if chord.mods.is_none() => p.push(c),
_ => {}
}
}
fn move_picker(&mut self, delta: isize) {
if let Some(p) = self.picker.as_mut() {
p.move_cursor(delta);
}
}
fn accept_picker(&mut self) {
let Some(p) = self.picker.as_ref() else {
return;
};
let Some(target) = p.accept().cloned() else {
self.note = Some(("no entry selected".into(), Note::Warn));
return;
};
self.picker = None;
match target {
Target::Workflow { namespace, run_id } => self.open_workflow(&namespace, &run_id),
Target::Row(row) => {
if row >= self.row_count() {
self.note = Some(("that row has gone".into(), Note::Warn));
return;
}
self.mark_jump();
self.set_cursor(row);
}
Target::Command(id) => self.run(&id, None),
Target::Pane(id) => self.focus_pane(ViewId(id)),
Target::Query(clause) => self.add_clause(&clause),
Target::Namespace(name) => self.switch_namespace(&name),
}
}
fn switch_namespace(&mut self, name: &str) {
self.mark_jump();
self.stop_following();
self.view.scope = vec![name.to_string()];
self.view.screen = Screen::Workflows;
self.view.viewing = None;
self.view.history = Loadable::NotAsked;
self.view.history_events.clear();
self.view.history_token.clear();
self.view.history_resume.clear();
self.view.cursor = 0;
self.view.cursor_key = None;
self.view.anchor = None;
self.mode = Mode::Normal;
self.note = Some((format!("namespace: {name}"), Note::Info));
self.load_workflows(false);
}
fn show_problems(&mut self) {
self.mark_jump();
const PROBLEMS: &str =
"ExecutionStatus IN ('Failed', 'TimedOut', 'Terminated') ORDER BY StartTime DESC";
self.stop_following();
self.view.query = PROBLEMS.to_string();
if self.view.screen != Screen::Workflows {
self.view.screen = Screen::Workflows;
self.view.viewing = None;
self.reset_history();
}
self.view.cursor = 0;
self.view.cursor_key = None;
self.note = Some(("problems: failed, timed out, terminated".into(), Note::Info));
self.load_workflows(false);
}
fn open_workflow(&mut self, namespace: &str, run_id: &str) {
let Some(row) = self
.workflow_rows()
.iter()
.find(|w| w.namespace == namespace && w.run_id == run_id)
.cloned()
else {
self.note = Some(("that workflow is no longer in the list".into(), Note::Warn));
return;
};
self.mark_jump();
self.view.workflow_cursor = self.view.cursor;
self.view.anchor = None;
self.mode = Mode::Normal;
self.view.screen = Screen::History;
self.view.viewing = Some(row);
self.view.cursor = 0;
self.reset_history();
self.load_history();
}
fn reset_history(&mut self) {
self.stop_following();
self.view.history = Loadable::NotAsked;
self.view.history_events.clear();
self.view.history_token.clear();
self.view.history_resume.clear();
}
fn focus_pane(&mut self, id: ViewId) {
if self.tabs.current().focused() == id {
return;
}
let previous = self.tabs.current().focused();
if self.tabs.current_mut().focus_view(id) {
self.refocus(previous);
return;
}
let start = self.tabs.index();
for _ in 1..self.tabs.len() {
self.tabs.next();
if self.tabs.current().views().contains(&id) {
self.tabs.current_mut().focus_view(id);
self.refocus(previous);
return;
}
}
while self.tabs.index() != start {
self.tabs.next();
}
self.note = Some(("that pane has gone".into(), Note::Warn));
}
fn add_clause(&mut self, clause: &str) {
self.mark_jump();
let ordering = clause
.trim_start()
.to_ascii_lowercase()
.starts_with("order by");
let current = self.view.query.trim().to_string();
self.view.query = if current.is_empty() {
clause.to_string()
} else if ordering {
format!("{current} {clause}")
} else {
format!("{current} AND {clause}")
};
if self.view.screen == Screen::Namespaces {
self.view.screen = Screen::Workflows;
}
self.note = Some((format!("query: {}", self.view.query), Note::Info));
self.load_workflows(false);
}
fn here(&self) -> Jump {
Jump {
screen: self.view.screen,
scope: self.view.scope.clone(),
query: self.view.query.clone(),
viewing: self.view.viewing.clone(),
cursor: self.view.cursor,
cursor_key: self.view.cursor_key.clone(),
}
}
fn mark_jump(&mut self) {
let here = self.here();
self.jumps.push(here);
}
fn jump(&mut self, back: bool) {
let here = self.here();
let target = if back {
self.jumps.back(here).cloned()
} else {
self.jumps.forward().cloned()
};
let Some(target) = target else {
self.note = Some((
if back {
"no earlier position".into()
} else {
"no later position".into()
},
Note::Warn,
));
return;
};
self.go_to_jump(target);
}
fn go_to_jump(&mut self, to: Jump) {
self.stop_following();
self.mode = Mode::Normal;
self.view.anchor = None;
self.view.scope = to.scope;
self.view.query = to.query;
self.view.screen = to.screen;
self.view.viewing = to.viewing;
self.view.cursor = to.cursor;
self.view.cursor_key = to.cursor_key;
match to.screen {
Screen::Namespaces => self.clamp_cursor(),
Screen::Workflows => self.load_workflows(false),
Screen::Schedules => self.load_schedules(),
Screen::History => {
self.view.history = Loadable::NotAsked;
self.view.history_events.clear();
self.view.history_token.clear();
self.view.history_resume.clear();
self.load_history();
}
}
}
fn open_search(&mut self) {
self.mode = Mode::Command;
self.prompt = Some(Prompt {
kind: PromptKind::Search,
buf: String::new(),
});
}
fn run_search(&mut self, pattern: String) {
self.search = Search::new(pattern);
if self.row_count() == 0 {
return;
}
let from = self.here();
if self.seek(self.view.cursor, true, true) {
self.jumps.push(from);
}
}
fn jump_match(&mut self, forward: bool) {
if self.search.is_empty() {
self.note = Some(("no search yet, press / first".into(), Note::Warn));
return;
}
self.seek(self.view.cursor, forward, false);
}
fn seek(&mut self, from: usize, forward: bool, inclusive: bool) -> bool {
let labels = self.view.search_labels();
let total = search::count(&self.search, &labels);
match search::find(&self.search, &labels, from, forward, inclusive) {
Some(hit) => {
self.set_cursor(hit.row);
let where_ = if hit.wrapped {
if forward {
" (wrapped to the top)"
} else {
" (wrapped to the bottom)"
}
} else {
""
};
self.note = Some((
format!("/{} {total} match(es){where_}", self.search.pattern()),
Note::Info,
));
true
}
None => {
self.note = Some((
format!("no match for /{}", self.search.pattern()),
Note::Warn,
));
false
}
}
}
fn jump_failure(&mut self, forward: bool) {
let Some(outline) = self.view.history.value() else {
return;
};
let found = if forward {
outline.next_failure(self.view.cursor)
} else {
outline.prev_failure(self.view.cursor)
};
match found {
Some(row) => self.view.cursor = row,
None => {
self.note = Some((
if forward {
"no failure below".into()
} else {
"no failure above".into()
},
Note::Warn,
));
}
}
}
fn select_view(&mut self, key: char) {
let Some(view) = self.views.iter().find(|v| v.key == key) else {
self.note = Some((format!("no saved view on `{key}`"), Note::Warn));
return;
};
let (name, query) = (view.name.clone(), view.query.clone());
self.mark_jump();
self.view.query = query;
if self.view.screen == Screen::Namespaces {
self.view.screen = Screen::Workflows;
}
self.note = Some((format!("view: {name}"), Note::Info));
self.load_workflows(false);
}
fn refresh(&mut self) {
self.decode_failed.clear();
match self.view.screen {
Screen::Namespaces => self.load_namespaces(),
Screen::Workflows => self.load_workflows(false),
Screen::History => {
self.view.history_events.clear();
self.view.history_token.clear();
self.view.history_resume.clear();
self.load_history();
}
Screen::Schedules => self.load_schedules(),
}
}
fn scroll_help(&mut self, delta: isize) {
let next = (self.help_scroll as isize + delta).clamp(0, self.help_max_scroll as isize);
self.help_scroll = next as usize;
}
fn move_cursor(&mut self, delta: isize) {
let len = self.row_count();
if len == 0 {
self.view.cursor = 0;
return;
}
let next = (self.view.cursor as isize + delta).clamp(0, len as isize - 1);
self.set_cursor(next as usize);
}
fn set_cursor(&mut self, at: usize) {
if at != self.view.cursor {
self.view.detail_scroll = 0;
self.view.piped = None;
}
self.view.cursor = at;
self.maybe_decode();
self.remember_cursor();
self.maybe_load_more();
}
fn remember_cursor(&mut self) {
if self.view.screen == Screen::Workflows {
self.view.cursor_key = self
.workflow_rows()
.get(self.view.cursor)
.map(|r| (r.namespace.clone(), r.run_id.clone()));
}
}
fn restore_cursor(&mut self) {
let Some((ns, run)) = self.view.cursor_key.clone() else {
self.clamp_cursor();
return;
};
if let Some(list) = self.view.workflows.value()
&& let Some(at) = list.position_of((&ns, &run))
{
self.view.cursor = at;
}
self.clamp_cursor();
}
fn clamp_cursor(&mut self) {
let len = self.row_count();
self.view.cursor = self.view.cursor.min(len.saturating_sub(1));
if len == 0 {
self.view.cursor = 0;
}
}
fn maybe_load_more(&mut self) {
if self.view.loading_more {
return;
}
let len = self.row_count();
let near_end = self.view.cursor + self.view.page.max(1) >= len;
if !near_end {
return;
}
match self.view.screen {
Screen::Workflows => {
if self
.view
.workflows
.value()
.is_some_and(WorkflowList::has_more)
{
self.load_more();
}
}
Screen::History => {
if !self.view.history_token.is_empty() {
self.load_history();
}
}
Screen::Namespaces | Screen::Schedules => {}
}
}
fn field_under_cursor(&self) -> String {
match self.view.screen {
Screen::Namespaces => self
.namespace_rows()
.get(self.view.cursor)
.map(|n| n.name.clone())
.unwrap_or_default(),
Screen::Workflows => self
.workflow_rows()
.get(self.view.cursor)
.map(|w| w.workflow_id.clone())
.unwrap_or_default(),
Screen::History => self.history_field_under_cursor(),
Screen::Schedules => self
.view
.schedule_rows()
.get(self.view.cursor)
.map(|s| s.schedule_id.clone())
.unwrap_or_default(),
}
}
fn history_field_under_cursor(&self) -> String {
let Some(outline) = self.view.history.value() else {
return String::new();
};
match outline.row_at(self.view.cursor) {
Some(Row::Group { group, .. }) => outline
.group(group)
.map(|g| {
if g.subject.is_empty() {
format!("{:?}", g.category)
} else {
g.subject.clone()
}
})
.unwrap_or_default(),
Some(Row::Event { event, .. }) => outline
.event(event)
.map(|e| e.name.to_string())
.unwrap_or_default(),
None => String::new(),
}
}
fn records_selected(&self) -> String {
let (lo, hi) = self
.selection()
.unwrap_or((self.view.cursor, self.view.cursor));
let take = hi.saturating_sub(lo) + 1;
let picked: Vec<String> = match self.view.screen {
Screen::Namespaces => self
.namespace_rows()
.iter()
.skip(lo)
.take(take)
.map(|n| {
format!(
r#"{{"name":{},"state":{},"retentionDays":{}}}"#,
json_string(&n.name),
json_string(&n.state),
n.retention_days
)
})
.collect(),
Screen::Workflows => self
.workflow_rows()
.iter()
.skip(lo)
.take(take)
.map(|w| {
format!(
r#"{{"namespace":{},"workflowId":{},"runId":{},"type":{},"taskQueue":{},"status":{},"historyLength":{}}}"#,
json_string(&w.namespace),
json_string(&w.workflow_id),
json_string(&w.run_id),
json_string(&w.workflow_type),
json_string(&w.task_queue),
json_string(w.status.query_name()),
w.history_length
)
})
.collect(),
Screen::History => self.history_records(lo, take),
Screen::Schedules => self
.view
.schedule_rows()
.iter()
.skip(lo)
.take(take)
.map(|s| {
format!(
r#"{{"namespace":{},"scheduleId":{},"workflowType":{},"paused":{},"spec":{}}}"#,
json_string(&s.namespace),
json_string(&s.schedule_id),
json_string(&s.workflow_type),
s.paused,
json_string(&s.spec)
)
})
.collect(),
};
match picked.len() {
0 => String::new(),
1 => picked.into_iter().next().unwrap(),
_ => format!("[{}]", picked.join(",")),
}
}
fn history_records(&self, lo: usize, take: usize) -> Vec<String> {
let Some(outline) = self.view.history.value() else {
return Vec::new();
};
(lo..lo.saturating_add(take))
.map_while(|r| outline.row_at(r))
.filter_map(|row| match row {
Row::Group { group, .. } => outline.group(group).map(|g| {
format!(
r#"{{"group":{},"category":{},"outcome":{},"attempts":{},"events":{}}}"#,
json_string(&g.subject),
json_string(&format!("{:?}", g.category)),
json_string(g.outcome.label()),
g.attempts,
g.events.len()
)
}),
Row::Event { event, .. } => outline.event(event).map(|e| {
format!(
r#"{{"eventId":{},"event":{},"subject":{}}}"#,
e.id,
json_string(e.name),
json_string(&e.subject)
)
}),
})
.collect()
}
fn yank_payload(&mut self, part: PayloadPart) {
if self.view.screen != Screen::History {
self.note = Some(("payloads are on a workflow history".into(), Note::Warn));
return;
}
let picked: Vec<_> = self
.payloads_under_cursor()
.into_iter()
.filter(|(label, _)| match part {
PayloadPart::All => true,
PayloadPart::Input => label == "input" || label.starts_with("input["),
PayloadPart::Result => label == "result" || label.starts_with("result["),
})
.collect();
if picked.is_empty() {
self.note = Some((
match part {
PayloadPart::All => "nothing to yank here".into(),
PayloadPart::Input => "no input on this row".to_string(),
PayloadPart::Result => "no result on this row".to_string(),
},
Note::Warn,
));
return;
}
let (json, skipped) = tmprl_core::payload::payloads_as_json(&picked);
let Some(json) = json else {
self.note = Some((
format!("cannot yank: {} is not decoded text", skipped.join(", ")),
Note::Warn,
));
return;
};
let text = if picked.len() == 1 {
tmprl_core::payload::unwrap_single(&json).unwrap_or(json)
} else {
json
};
self.yank(text);
if !skipped.is_empty()
&& let Some((note, _)) = self.note.as_mut()
{
note.push_str(&format!(" ({} skipped)", skipped.join(", ")));
}
}
fn yank(&mut self, text: String) {
if text.is_empty() {
self.note = Some(("nothing to yank".into(), Note::Warn));
return;
}
let n = text.len();
match crate::clipboard::yank(&text) {
Ok(()) => {
self.note = Some((format!("yanked {n} bytes to clipboard"), Note::Info));
self.view.anchor = None;
self.mode = Mode::Normal;
}
Err(e) => self.note = Some((format!("yank failed: {e}"), Note::Error)),
}
}
fn insert_keys(&mut self, flushed: Vec<Chord>) {
use tmprl_core::Key;
for c in flushed {
match c.key {
Key::Backspace if c.mods.is_none() => {
self.insert_buf.pop();
}
Key::Enter if c.mods.is_none() => self.commit_insert(),
_ => {
if let Some(ch) = c.as_insertable() {
self.insert_buf.push(ch);
}
}
}
}
}
fn commit_insert(&mut self) {
if self.insert_target != InsertTarget::Query {
return;
}
self.view.query = self.insert_buf.clone();
self.mode = Mode::Normal;
self.insert_target = InsertTarget::Scratch;
self.insert_buf.clear();
self.load_workflows(false);
}
fn prompt_key(&mut self, chord: Chord) {
use tmprl_core::Key;
let Some(prompt) = self.prompt.as_mut() else {
return;
};
match chord.key {
Key::Esc => self.close_prompt(),
Key::Enter => {
let entered = prompt.buf.trim().to_string();
let kind = prompt.kind;
self.close_prompt();
if entered.is_empty() {
return;
}
match kind {
PromptKind::Command => self.run_typed_command(&entered),
PromptKind::Pipe => self.run_pipe(entered),
PromptKind::Search => self.run_search(entered),
PromptKind::Signal | PromptKind::Update => self.confirm_named(kind, entered),
PromptKind::Backfill => self.confirm_backfill(entered),
}
}
Key::Backspace if prompt.buf.pop().is_none() => self.close_prompt(),
Key::Backspace => {}
Key::Char(c) if chord.mods.is_none() => prompt.buf.push(c),
_ => {}
}
}
fn refocus(&mut self, previous: ViewId) {
let now = self.tabs.current().focused();
if now == previous {
return;
}
let namespace = self.namespace.clone();
let incoming = self
.parked
.remove(&now)
.unwrap_or_else(|| View::new(&namespace));
let outgoing = std::mem::replace(&mut self.view, incoming);
self.parked.insert(previous, outgoing);
}
fn fresh_view_id(&mut self) -> ViewId {
let id = ViewId(self.next_view_id);
self.next_view_id += 1;
id
}
fn split(&mut self, axis: Axis) {
let previous = self.tabs.current().focused();
let id = self.fresh_view_id();
let forked = self.view.fork();
self.parked.insert(id, forked);
self.tabs.current_mut().split(axis, id);
self.refocus(previous);
self.load_for_screen();
self.note = Some((format!("{} windows", self.tabs.current().len()), Note::Info));
}
fn close_window(&mut self) {
let previous = self.tabs.current().focused();
if !self.tabs.current_mut().close() {
self.note = Some(("last window, <Space>q to quit tmprl".into(), Note::Warn));
return;
}
self.parked.remove(&previous);
let now = self.tabs.current().focused();
let namespace = self.namespace.clone();
let incoming = self
.parked
.remove(&now)
.unwrap_or_else(|| View::new(&namespace));
self.view = incoming;
}
fn focus_window(&mut self, dir: Direction) {
let previous = self.tabs.current().focused();
if self.tabs.current_mut().focus_direction(dir, self.frame) {
self.refocus(previous);
}
}
fn resize_window(&mut self, dir: Direction) {
self.tabs.current_mut().resize(dir, 10);
}
fn new_tab(&mut self) {
let previous = self.tabs.current().focused();
let id = self.fresh_view_id();
self.parked.insert(
previous,
std::mem::replace(&mut self.view, View::new(&self.namespace)),
);
self.tabs.open(id);
let _ = previous;
self.load_for_screen();
}
fn close_tab(&mut self) {
if self.tabs.len() == 1 {
self.note = Some(("last tab, <Space>q to quit tmprl".into(), Note::Warn));
return;
}
for id in self.tabs.current().views() {
self.parked.remove(&id);
}
self.tabs.close();
let now = self.tabs.current().focused();
let namespace = self.namespace.clone();
self.view = self
.parked
.remove(&now)
.unwrap_or_else(|| View::new(&namespace));
}
fn switch_tab(&mut self, forward: bool) {
if self.tabs.len() == 1 {
return;
}
let previous = self.tabs.current().focused();
self.parked.insert(
previous,
std::mem::replace(&mut self.view, View::new(&self.namespace)),
);
if forward {
self.tabs.next();
} else {
self.tabs.previous();
}
let now = self.tabs.current().focused();
let namespace = self.namespace.clone();
self.view = self
.parked
.remove(&now)
.unwrap_or_else(|| View::new(&namespace));
}
fn load_for_screen(&mut self) {
match self.view.screen {
Screen::Namespaces => self.load_namespaces(),
Screen::Workflows => self.load_workflows(false),
Screen::History => self.load_history(),
Screen::Schedules => self.load_schedules(),
}
}
pub fn set_frame(&mut self, area: UiRect) {
self.frame = area;
}
pub fn parked_view(&self, id: ViewId) -> Option<&View> {
self.parked.get(&id)
}
fn go_to(&mut self, screen: Screen) {
match self.view.screen {
Screen::Namespaces => {
self.note = Some(("open a namespace first".into(), Note::Warn));
return;
}
Screen::History => {
self.note = Some(("go up with `-` first".into(), Note::Warn));
return;
}
_ if self.view.screen == screen => return,
_ => {}
}
self.view.stop_following();
self.view.screen = screen;
self.view.cursor = 0;
self.view.anchor = None;
self.load_for_screen();
}
pub fn load_schedules(&mut self) {
self.view.generation = self.view.generation.wrapping_add(1);
self.view.schedules.begin_refresh();
self.view.loading_more = true;
let Some(conn) = self.conn.clone() else {
return;
};
let namespace = self
.view
.scope
.first()
.cloned()
.unwrap_or_else(|| self.namespace.clone());
let (tx, generation) = (self.tx.clone(), self.view.generation);
tokio::spawn(async move {
let result = conn
.list_schedules(&namespace, PAGE_SIZE, Vec::new())
.await
.map(|p| p.rows)
.map_err(|e| e.to_string());
let _ = tx.send(Msg::Schedules { generation, result });
});
}
fn target_schedule(&self) -> Option<ScheduleRow> {
if self.view.screen != Screen::Schedules {
return None;
}
self.view.schedule_rows().get(self.view.cursor).cloned()
}
fn target_workflows(&self) -> Vec<WorkflowRow> {
if self.view.screen == Screen::Workflows
&& let Some((lo, hi)) = self.view.selection()
{
let rows = self.view.workflow_rows();
return rows[lo.min(rows.len())..(hi + 1).min(rows.len())].to_vec();
}
self.target_workflow().into_iter().collect()
}
fn target_schedules(&self) -> Vec<ScheduleRow> {
if self.view.screen == Screen::Schedules
&& let Some((lo, hi)) = self.view.selection()
{
let rows = self.view.schedule_rows();
return rows[lo.min(rows.len())..(hi + 1).min(rows.len())].to_vec();
}
self.target_schedule().into_iter().collect()
}
fn target_workflow(&self) -> Option<WorkflowRow> {
match self.view.screen {
Screen::Workflows => self.view.workflow_rows().get(self.view.cursor).cloned(),
Screen::History => self.view.viewing.clone(),
Screen::Namespaces | Screen::Schedules => None,
}
}
fn confirm_mutation(&mut self, kind: MutationKind) {
if self.refuses_mutation() {
return;
}
if matches!(
kind,
MutationKind::PauseSchedule
| MutationKind::TriggerSchedule
| MutationKind::DeleteSchedule
| MutationKind::BackfillSchedule
) {
let rows = self.target_schedules();
if rows.is_empty() {
self.note = Some(("no schedule under the cursor".into(), Note::Warn));
return;
}
if matches!(kind, MutationKind::BackfillSchedule) {
self.prompt = Some(Prompt {
kind: PromptKind::Backfill,
buf: String::new(),
});
self.mode = Mode::Command;
return;
}
let target_paused = !rows[0].paused;
let mutations = rows
.into_iter()
.map(|row| {
let (namespace, schedule_id) = (row.namespace, row.schedule_id);
match kind {
MutationKind::PauseSchedule => Mutation::PauseSchedule {
namespace,
schedule_id,
paused: target_paused,
},
MutationKind::TriggerSchedule => Mutation::TriggerSchedule {
namespace,
schedule_id,
},
_ => Mutation::DeleteSchedule {
namespace,
schedule_id,
},
}
})
.collect();
self.confirm = Some(Confirm::batch(mutations));
return;
}
if matches!(kind, MutationKind::Signal | MutationKind::Update) {
if self.target_workflows().is_empty() {
self.note = Some(("no workflow under the cursor".into(), Note::Warn));
return;
}
self.prompt = Some(Prompt {
kind: if matches!(kind, MutationKind::Signal) {
PromptKind::Signal
} else {
PromptKind::Update
},
buf: String::new(),
});
self.mode = Mode::Command;
return;
}
if matches!(kind, MutationKind::Reset) {
let Some(row) = self.target_workflow() else {
self.note = Some(("no workflow under the cursor".into(), Note::Warn));
return;
};
let Some(event_id) = self.reset_target() else {
self.note = Some((
"reset needs a workflow history with a completed workflow task above \
the cursor"
.into(),
Note::Warn,
));
return;
};
self.confirm = Some(Confirm::new(Mutation::Reset {
namespace: row.namespace,
workflow_id: row.workflow_id,
run_id: row.run_id,
event_id,
reason: "reset from tmprl".into(),
}));
return;
}
let rows = self.target_workflows();
if rows.is_empty() {
self.note = Some(("no workflow under the cursor".into(), Note::Warn));
return;
}
let mutations = rows
.into_iter()
.map(|row| {
let (namespace, workflow_id, run_id) = (row.namespace, row.workflow_id, row.run_id);
match kind {
MutationKind::Terminate => Mutation::Terminate {
namespace,
workflow_id,
run_id,
reason: "terminated from tmprl".into(),
},
MutationKind::Delete => Mutation::Delete {
namespace,
workflow_id,
run_id,
},
_ => Mutation::Cancel {
namespace,
workflow_id,
run_id,
},
}
})
.collect();
self.confirm = Some(Confirm::batch(mutations));
}
fn confirm_named(&mut self, kind: PromptKind, name: String) {
let rows = self.target_workflows();
if rows.is_empty() {
return;
}
let mutations = rows
.into_iter()
.map(|row| {
let (namespace, workflow_id, run_id) = (row.namespace, row.workflow_id, row.run_id);
let name = name.clone();
match kind {
PromptKind::Update => Mutation::Update {
namespace,
workflow_id,
run_id,
name,
input: None,
},
_ => Mutation::Signal {
namespace,
workflow_id,
run_id,
name,
input: None,
},
}
})
.collect();
self.confirm = Some(Confirm::batch(mutations));
}
fn confirm_backfill(&mut self, entered: String) {
let Some(row) = self.target_schedule() else {
return;
};
match parse_backfill(&entered, now_ms()) {
Ok((range, overlap)) => {
self.confirm = Some(Confirm::new(Mutation::BackfillSchedule {
namespace: row.namespace,
schedule_id: row.schedule_id,
range,
overlap,
}));
}
Err(e) => self.note = Some((e, Note::Warn)),
}
}
fn open_new_schedule_form(&mut self) {
self.form = Some(Form::new_schedule());
}
fn form_key(&mut self, chord: Chord) {
use tmprl_core::Key;
let Some(form) = self.form.as_mut() else {
return;
};
match chord.key {
Key::Esc => {
self.form = None;
self.mode = Mode::Normal;
}
Key::Tab => form.next(),
Key::BackTab => form.previous(),
Key::Down => form.next(),
Key::Up => form.previous(),
Key::Enter => self.confirm_new_schedule(),
Key::Backspace if !form.backspace() => form.previous(),
Key::Backspace => {}
Key::Char(c) if chord.mods.is_none() => form.push(c),
_ => {}
}
}
fn confirm_new_schedule(&mut self) {
let Some(form) = self.form.as_mut() else {
return;
};
if let Some(label) = form.missing() {
form.focus(label);
self.note = Some((format!("{label} is required"), Note::Warn));
return;
}
let input = form.get("input");
let mutation = Mutation::CreateSchedule {
namespace: self
.view
.scope
.first()
.cloned()
.unwrap_or_else(|| self.namespace.clone()),
schedule_id: form.get("schedule id").to_string(),
workflow_id: form.get("workflow id").to_string(),
workflow_type: form.get("workflow type").to_string(),
task_queue: form.get("task queue").to_string(),
spec: form.get("spec").to_string(),
input: (!input.is_empty()).then(|| input.to_string()),
};
self.form = None;
self.confirm = Some(Confirm::new(mutation));
}
fn reset_target(&self) -> Option<i64> {
if self.view.screen != Screen::History {
return None;
}
let outline = self.view.history.value()?;
let at = match outline.row_at(self.view.cursor)? {
Row::Event { event, .. } => outline.event(event)?.id,
Row::Group { group, .. } => *outline.group(group)?.events.last()?,
};
tmprl_core::history::reset_point(outline.events(), at)
}
fn confirm_key(&mut self, chord: Chord) {
use tmprl_core::Key;
let Some(confirm) = self.confirm.as_mut() else {
return;
};
match chord.key {
Key::Esc => {
self.confirm = None;
self.note = Some(("cancelled".into(), Note::Info));
}
Key::Enter if confirm.is_satisfied() => {
let mutations = std::mem::take(&mut confirm.mutations);
self.confirm = None;
self.view.anchor = None;
self.mode = Mode::Normal;
self.run_mutations(mutations);
}
Key::Enter => {}
Key::Backspace => {
confirm.entered.pop();
}
Key::Char(c) if chord.mods.is_none() => confirm.entered.push(c),
_ => {}
}
}
fn run_mutations(&mut self, mutations: Vec<Mutation>) {
if self.refuses_mutation() {
return;
}
let Some(conn) = self.conn.clone() else {
return;
};
let Some(first) = mutations.first() else {
return;
};
let total = mutations.len();
self.note = Some((
if total > 1 {
format!("{} {total}…", first.verb().to_lowercase())
} else {
format!("{}…", first.verb().to_lowercase())
},
Note::Info,
));
let tx = self.tx.clone();
tokio::spawn(async move {
for (i, mutation) in mutations.into_iter().enumerate() {
let result = conn.mutate(&mutation).await.map_err(|e| e.to_string());
let _ = tx.send(Msg::Mutated {
mutation: Box::new(mutation),
result,
batch: (total > 1).then_some((i + 1, total)),
});
}
});
}
pub fn accent(&self) -> Option<tmprl_core::config::Accent> {
self.accent
}
pub fn readonly(&self) -> bool {
self.readonly
}
fn refuses_mutation(&mut self) -> bool {
if self.readonly {
self.note = Some((format!("profile {} is read-only", self.profile), Note::Warn));
return true;
}
false
}
fn audit(&mut self, mutation: &Mutation, outcome: &str) {
let target = tmprl_core::mutation::Target {
profile: &self.profile,
address: &self.address,
};
if let Err(e) = crate::config::append_audit(&mutation.audit_line(now_ms(), target, outcome))
{
self.note = Some((format!("audit log: {e}"), Note::Error));
}
}
fn payload_key(p: &Payload) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
p.encoding.hash(&mut h);
p.data.hash(&mut h);
h.finish()
}
pub fn decode_state(&self, p: &Payload) -> DecodeState {
let key = Self::payload_key(p);
if self.codec.is_none() {
DecodeState::NoCodec
} else if self.decoding.contains(&key) {
DecodeState::InFlight
} else if let Some(why) = self.decode_failed.get(&key) {
DecodeState::Failed(why.clone())
} else {
DecodeState::Idle
}
}
fn maybe_decode(&mut self) {
if !self.view.show_detail || self.view.screen != Screen::History {
return;
}
let Some(codec) = self.codec.clone() else {
return;
};
let wanted: Vec<Payload> = self
.payloads_under_cursor()
.into_iter()
.map(|(_, p)| p)
.filter(|p| p.needs_codec())
.filter(|p| {
let key = Self::payload_key(p);
!self.decoded.contains_key(&key) && !self.decoding.contains(&key)
})
.collect();
if wanted.is_empty() {
return;
}
for p in &wanted {
self.decoding.insert(Self::payload_key(p));
}
let keys: Vec<u64> = wanted.iter().map(Self::payload_key).collect();
let namespace = self
.view
.viewing
.as_ref()
.map(|w| w.namespace.clone())
.unwrap_or_default();
let tx = self.tx.clone();
tokio::spawn(async move {
let result = codec
.decode(&namespace, &wanted)
.await
.map(|out| keys.into_iter().zip(out).collect::<Vec<_>>())
.map_err(|e| e.to_string());
let _ = tx.send(Msg::Decoded(result));
});
}
fn apply_decoded(&mut self) {
if self.decoded.is_empty() {
return;
}
let mut changed = false;
for event in &mut self.view.history_events {
for (_, p) in &mut event.payloads {
if !p.needs_codec() {
continue;
}
if let Some(plain) = self.decoded.get(&Self::payload_key(p)) {
*p = plain.clone();
changed = true;
}
}
}
if !changed {
return;
}
let groups = group_events(&self.view.history_events);
let events = self.view.history_events.clone();
match self.view.history.value_mut() {
Some(outline) => outline.replace(events, groups),
None => self.view.history = Loadable::loaded(Outline::new(events, groups)),
}
}
fn payloads_under_cursor(&self) -> Vec<(String, tmprl_core::payload::Payload)> {
let Some(outline) = self.view.history.value() else {
return Vec::new();
};
match outline.row_at(self.view.cursor) {
Some(Row::Event { event, .. }) => outline
.event(event)
.map(|e| e.payloads.clone())
.unwrap_or_default(),
Some(Row::Group { group, .. }) => {
let Some(g) = outline.group(group) else {
return Vec::new();
};
let mut out = Vec::new();
for id in [g.events.first(), g.events.last()].into_iter().flatten() {
if let Some(e) = outline.events().iter().find(|e| e.id == *id) {
out.extend(e.payloads.iter().cloned());
}
}
out
}
None => Vec::new(),
}
}
fn open_pipe(&mut self) {
if self.view.screen != Screen::History {
self.note = Some(("piping applies to a workflow history".into(), Note::Warn));
return;
}
let payloads = self.payloads_under_cursor();
if payloads.is_empty() {
self.note = Some(("nothing here to pipe".into(), Note::Warn));
return;
}
if tmprl_core::payload::payloads_as_json(&payloads).0.is_none() {
self.note = Some((
"no readable payload here, encrypted or binary".into(),
Note::Warn,
));
return;
}
self.prompt = Some(Prompt {
kind: PromptKind::Pipe,
buf: "jq .".into(),
});
self.mode = Mode::Command;
}
fn run_pipe(&mut self, command: String) {
let (json, skipped) = tmprl_core::payload::payloads_as_json(&self.payloads_under_cursor());
let Some(json) = json else {
self.note = Some(("nothing readable to pipe".into(), Note::Warn));
return;
};
if !skipped.is_empty() {
self.note = Some((
format!("piping without {} (not readable)", skipped.join(", ")),
Note::Warn,
));
}
self.view.show_detail = true;
self.view.detail_scroll = 0;
self.view.piped = Some(Ok(format!("running `{command}`…")));
let tx = self.tx.clone();
tokio::spawn(async move {
let result = pipe_through(&command, json.into_bytes()).await;
let _ = tx.send(Msg::Piped(result));
});
}
fn open_editor(&mut self) {
if self.view.screen != Screen::History {
self.note = Some(("payloads live in a workflow history".into(), Note::Warn));
return;
}
let (json, skipped) = tmprl_core::payload::payloads_as_json(&self.payloads_under_cursor());
let Some(json) = json else {
self.note = Some(("nothing readable here to open".into(), Note::Warn));
return;
};
let dir = std::env::temp_dir().join(format!("tmprl-{}", uuid::Uuid::new_v4()));
if let Err(e) = create_private_dir(&dir) {
self.note = Some((
format!("could not create {}: {e}", dir.display()),
Note::Error,
));
return;
}
let stem = self
.view
.viewing
.as_ref()
.map(|w| w.run_id.clone())
.unwrap_or_else(|| "payload".into());
let path = dir.join(format!("{stem}.json"));
if let Err(e) = write_private_file(&path, json.as_bytes()) {
let _ = std::fs::remove_dir_all(&dir);
self.note = Some((
format!("could not write {}: {e}", path.display()),
Note::Error,
));
return;
}
let mut what = "a read-only copy; edits are not saved back".to_string();
if !skipped.is_empty() {
what = format!("{what}; without {} (not readable)", skipped.join(", "));
}
self.editing = Some(EditRequest { path, dir, what });
}
pub fn take_edit_request(&mut self) -> Option<EditRequest> {
self.editing.take()
}
pub fn finish_edit(&mut self, req: &EditRequest, error: Option<String>) {
self.dirty = true;
let _ = std::fs::remove_dir_all(&req.dir);
self.note = Some(match error {
Some(e) => (format!("editor: {e}"), Note::Error),
None => (
format!("closed {} — {}", req.path.display(), req.what),
Note::Info,
),
});
}
fn close_prompt(&mut self) {
self.prompt = None;
self.mode = Mode::Normal;
}
fn run_typed_command(&mut self, entered: &str) {
let hits = self.registry.search(entered);
match hits.iter().find(|c| c.id == entered).or(hits.first()) {
Some(c) if hits.len() == 1 || c.id == entered => {
let id = c.id;
self.run(id, None);
}
Some(_) => {
self.note = Some((
format!("ambiguous: {} commands match `{entered}`", hits.len()),
Note::Warn,
));
}
None => {
self.note = Some((format!("no such command: {entered}"), Note::Error));
}
}
}
pub fn cmdline_matches(&self) -> Vec<&tmprl_core::Command> {
match &self.prompt {
Some(p) if p.kind == PromptKind::Command => {
self.registry.search(&p.buf).into_iter().take(8).collect()
}
_ => Vec::new(),
}
}
pub fn load_namespaces(&mut self) {
let Some(conn) = self.conn.clone() else {
return;
};
self.view.namespaces.begin_refresh();
let tx = self.tx.clone();
tokio::spawn(async move {
let res = conn.list_namespaces().await.map_err(|e| e.to_string());
let _ = tx.send(Msg::Namespaces(res));
});
}
pub fn load_workflows(&mut self, append: bool) {
if !append {
self.view.generation = self.view.generation.wrapping_add(1);
self.view.workflows.begin_refresh();
self.view.counts.begin_refresh();
self.load_counts();
}
self.view.loading_more = true;
let Some(conn) = self.conn.clone() else {
return;
};
let tokens: Tokens = if append {
self.view
.workflows
.value()
.map(|l| l.tokens().to_vec())
.unwrap_or_default()
} else {
Vec::new()
};
let (tx, generation, scope, query) = (
self.tx.clone(),
self.view.generation,
self.view.scope.clone(),
self.view.query.clone(),
);
tokio::spawn(async move {
let result = if append {
conn.continue_workflows_across(&tokens, &query, PAGE_SIZE)
.await
} else {
conn.list_workflows_across(&scope, &query, PAGE_SIZE).await
}
.map_err(|e| e.to_string());
let _ = tx.send(Msg::Workflows {
generation,
append,
result,
});
});
}
pub fn load_history(&mut self) {
let Some(row) = self.view.viewing.clone() else {
return;
};
if self.view.history_events.is_empty() {
self.view.generation = self.view.generation.wrapping_add(1);
self.view.history.begin_refresh();
}
self.view.loading_more = true;
let Some(conn) = self.conn.clone() else {
return;
};
let (tx, generation, token) = (
self.tx.clone(),
self.view.generation,
self.view.history_token.clone(),
);
tokio::spawn(async move {
let result = conn
.get_history(
&row.namespace,
&row.workflow_id,
&row.run_id,
HISTORY_PAGE_SIZE,
token,
)
.await
.map(|p| (p.events, p.next_page_token))
.map_err(|e| e.to_string());
let _ = tx.send(Msg::History { generation, result });
});
}
fn load_more(&mut self) {
let has_more = self
.view
.workflows
.value()
.is_some_and(WorkflowList::has_more);
if has_more {
self.load_workflows(true);
}
}
fn load_counts(&mut self) {
let Some(conn) = self.conn.clone() else {
return;
};
let (tx, generation, scope, query) = (
self.tx.clone(),
self.view.generation,
self.view.scope.clone(),
self.view.query.clone(),
);
tokio::spawn(async move {
let result = conn
.count_workflows_across(&scope, &query)
.await
.map_err(|e| e.to_string());
let _ = tx.send(Msg::Counts { generation, result });
});
}
}
async fn pipe_through(command: &str, input: Vec<u8>) -> Result<String, String> {
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
let mut child = Command::new("sh")
.arg("-c")
.arg(command)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("could not run `{command}`: {e}"))?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(&input).await;
let _ = stdin.shutdown().await;
}
let out = child
.wait_with_output()
.await
.map_err(|e| format!("`{command}` failed: {e}"))?;
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
if out.status.success() {
Ok(stdout)
} else {
Err(if stderr.trim().is_empty() {
format!("`{command}` exited with {}", out.status)
} else {
stderr
})
}
}
fn is_permission_denied(e: &str) -> bool {
let e = e.to_ascii_lowercase();
e.contains("permissiondenied")
|| e.contains("permission denied")
|| e.contains("does not have permission")
|| e.contains("request unauthorized")
}
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
use tmprl_core::{Key, WorkflowStatus};
use tokio::sync::mpsc::unbounded_channel;
fn app() -> App {
let (tx, _rx) = unbounded_channel();
App::detached("prod", "default", tx)
}
fn wf(ns: &str, run: &str, start: i64) -> WorkflowRow {
WorkflowRow {
namespace: ns.into(),
workflow_id: format!("order-{run}"),
run_id: run.into(),
workflow_type: "Checkout".into(),
task_queue: "tq".into(),
status: WorkflowStatus::Running,
start_time: Some(start),
close_time: None,
history_length: 4,
}
}
fn loaded(app: &mut App, rows: Vec<WorkflowRow>, tokens: Tokens) {
app.view.screen = Screen::Workflows;
app.handle(Msg::Workflows {
generation: app.view.generation,
append: false,
result: Ok((rows, tokens)),
});
}
fn type_chars(app: &mut App, s: &str) {
for c in s.chars() {
app.handle(Msg::Key(Chord::ch(c)));
}
}
fn search_for(app: &mut App, pattern: &str) {
app.run("search.open", None);
type_chars(app, pattern);
app.handle(Msg::Key(Chord::plain(Key::Enter)));
}
fn four(app: &mut App) {
let mut rows = vec![
wf("default", "r1", 100),
wf("default", "r2", 200),
wf("default", "r3", 300),
wf("default", "r4", 400),
];
rows[1].workflow_type = "Refund".into();
rows[3].workflow_type = "Refund".into();
loaded(app, rows, vec![]);
}
fn at_cursor(app: &App) -> String {
app.workflow_rows()[app.view.cursor].run_id.clone()
}
fn row_of(app: &App, run: &str) -> usize {
app.workflow_rows()
.iter()
.position(|w| w.run_id == run)
.expect("run should be in the list")
}
#[test]
fn slash_opens_a_prompt_that_says_it_is_a_search() {
let mut app = app();
four(&mut app);
app.run("search.open", None);
let prompt = app.prompt.as_ref().expect("/ should open a prompt");
assert_eq!(prompt.kind, PromptKind::Search);
assert_eq!(prompt.sigil(), "/");
assert_eq!(prompt.buf, "", "a new search starts empty, not pre-filled");
}
#[test]
fn a_search_moves_the_cursor_to_the_first_match() {
let mut app = app();
four(&mut app);
search_for(&mut app, "refund");
assert_eq!(
at_cursor(&app),
"r4",
"r4 is the first Refund in display order"
);
}
#[test]
fn a_search_can_match_the_row_the_cursor_is_already_on() {
let mut app = app();
four(&mut app);
app.view.cursor = row_of(&app, "r2");
search_for(&mut app, "refund");
assert_eq!(
at_cursor(&app),
"r2",
"should have stayed on the visible match"
);
}
#[test]
fn n_walks_to_the_next_match_and_wraps() {
let mut app = app();
four(&mut app);
search_for(&mut app, "refund");
assert_eq!(at_cursor(&app), "r4");
app.run("search.next", None);
assert_eq!(at_cursor(&app), "r2", "the other Refund, further down");
app.run("search.next", None);
assert_eq!(at_cursor(&app), "r4", "wrapped back to the first");
let (msg, _) = app.note.clone().expect("a wrap must announce itself");
assert!(msg.contains("wrapped"), "got: {msg}");
}
#[test]
fn capital_n_walks_backwards() {
let mut app = app();
four(&mut app);
search_for(&mut app, "refund");
assert_eq!(at_cursor(&app), "r4");
app.run("search.previous", None);
assert_eq!(
at_cursor(&app),
"r2",
"backwards from the topmost match wraps to the bottom one"
);
}
#[test]
fn n_without_a_previous_search_says_so_rather_than_moving() {
let mut app = app();
four(&mut app);
app.view.cursor = 2;
app.run("search.next", None);
assert_eq!(app.view.cursor, 2, "nothing should have moved");
let (msg, level) = app.note.clone().expect("should have explained itself");
assert_eq!(level, Note::Warn);
assert!(msg.contains("/"), "got: {msg}");
}
#[test]
fn a_search_finds_a_run_id_that_is_not_on_screen() {
let mut app = app();
four(&mut app);
search_for(&mut app, "r3");
assert_eq!(at_cursor(&app), "r3");
}
#[test]
fn a_failed_search_reports_it_and_leaves_the_cursor_alone() {
let mut app = app();
four(&mut app);
app.view.cursor = 2;
search_for(&mut app, "nothing-matches-this");
assert_eq!(app.view.cursor, 2);
let (msg, level) = app.note.clone().expect("should have said no match");
assert_eq!(level, Note::Warn);
assert!(msg.contains("no match"), "got: {msg}");
}
#[test]
fn the_match_count_is_reported() {
let mut app = app();
four(&mut app);
search_for(&mut app, "refund");
let (msg, _) = app.note.clone().expect("a search should report its count");
assert!(msg.contains("2 match"), "got: {msg}");
}
#[test]
fn the_pattern_survives_so_n_keeps_working_after_a_refresh() {
let mut app = app();
four(&mut app);
search_for(&mut app, "refund");
four(&mut app);
assert_eq!(app.search.pattern(), "refund");
app.run("search.next", None);
assert!(
app.workflow_rows()[app.view.cursor]
.workflow_type
.contains("Refund")
);
}
fn type_into_picker(app: &mut App, s: &str) {
for c in s.chars() {
app.handle(Msg::Key(Chord::ch(c)));
}
}
fn picker_labels(app: &App) -> Vec<String> {
app.picker
.as_ref()
.expect("a picker should be open")
.rows()
.map(|(i, _)| i.label.clone())
.collect()
}
#[test]
fn the_workflow_picker_lists_every_loaded_workflow() {
let mut app = app();
four(&mut app);
app.run("find.workflow", None);
assert_eq!(picker_labels(&app).len(), 4);
}
#[test]
fn the_picker_owns_the_keyboard_while_it_is_open() {
let mut app = app();
four(&mut app);
let before = app.view.cursor;
app.run("find.workflow", None);
app.handle(Msg::Key(Chord::ch('j')));
assert_eq!(app.view.cursor, before, "the list must not have moved");
assert_eq!(app.picker.as_ref().unwrap().prompt, "j");
}
#[test]
fn typing_narrows_the_workflow_picker() {
let mut app = app();
four(&mut app);
app.run("find.workflow", None);
type_into_picker(&mut app, "r2");
let shown = picker_labels(&app);
assert_eq!(shown, vec!["order-r2"], "got {shown:?}");
}
#[test]
fn accepting_a_workflow_opens_its_history() {
let mut app = app();
four(&mut app);
app.run("find.workflow", None);
type_into_picker(&mut app, "r2");
app.handle(Msg::Key(Chord::plain(Key::Enter)));
assert!(app.picker.is_none(), "accepting closes the picker");
assert_eq!(app.view.screen, Screen::History);
assert_eq!(
app.view.viewing.as_ref().map(|w| w.run_id.as_str()),
Some("r2")
);
}
#[test]
fn ctrl_n_and_ctrl_p_move_the_picker_cursor() {
let mut app = app();
four(&mut app);
app.run("find.workflow", None);
app.handle(Msg::Key(Chord::ctrl('n')));
assert_eq!(app.picker.as_ref().unwrap().cursor, 1);
app.handle(Msg::Key(Chord::ctrl('p')));
assert_eq!(app.picker.as_ref().unwrap().cursor, 0);
}
#[test]
fn esc_closes_a_picker_without_taking_anything() {
let mut app = app();
four(&mut app);
let before = app.view.screen;
app.run("find.workflow", None);
app.handle(Msg::Key(Chord::plain(Key::Esc)));
assert!(app.picker.is_none());
assert_eq!(app.view.screen, before, "nothing should have been opened");
}
#[test]
fn backspace_on_an_empty_picker_prompt_closes_it() {
let mut app = app();
four(&mut app);
app.run("find.workflow", None);
type_into_picker(&mut app, "r");
app.handle(Msg::Key(Chord::plain(Key::Backspace)));
assert!(app.picker.is_some(), "that backspace deleted the 'r'");
app.handle(Msg::Key(Chord::plain(Key::Backspace)));
assert!(app.picker.is_none(), "empty, so it closes");
}
#[test]
fn a_picker_with_nothing_to_show_says_why_instead_of_opening() {
let mut app = app();
app.view.screen = Screen::Namespaces;
app.run("find.workflow", None);
assert!(app.picker.is_none());
let (msg, level) = app.note.clone().expect("should have explained itself");
assert_eq!(level, Note::Warn);
assert!(msg.contains("no workflows"), "got: {msg}");
}
#[test]
fn the_filter_builder_offers_the_types_actually_loaded() {
let mut app = app();
four(&mut app);
app.run("find.filter", None);
let offered = picker_labels(&app);
assert!(
offered.iter().any(|l| l == "WorkflowType = 'Refund'"),
"got {offered:?}"
);
assert!(offered.iter().any(|l| l == "ExecutionStatus = 'Running'"));
}
#[test]
fn a_filter_clause_is_anded_onto_the_query_already_there() {
let mut app = app();
four(&mut app);
app.view.query = "WorkflowType = 'Checkout'".into();
app.run("find.filter", None);
type_into_picker(&mut app, "Running");
app.handle(Msg::Key(Chord::plain(Key::Enter)));
assert_eq!(
app.view.query,
"WorkflowType = 'Checkout' AND ExecutionStatus = 'Running'"
);
}
#[test]
fn a_filter_clause_on_an_empty_query_stands_alone() {
let mut app = app();
four(&mut app);
app.view.query.clear();
app.run("find.filter", None);
type_into_picker(&mut app, "Running");
app.handle(Msg::Key(Chord::plain(Key::Enter)));
assert_eq!(app.view.query, "ExecutionStatus = 'Running'");
}
#[test]
fn an_order_by_clause_is_appended_rather_than_anded() {
let mut app = app();
four(&mut app);
app.view.query = "ExecutionStatus = 'Running'".into();
app.run("find.filter", None);
type_into_picker(&mut app, "ORDER BY StartTime DESC");
app.handle(Msg::Key(Chord::plain(Key::Enter)));
assert_eq!(
app.view.query,
"ExecutionStatus = 'Running' ORDER BY StartTime DESC"
);
}
#[test]
fn the_command_picker_runs_what_it_accepts() {
let mut app = app();
four(&mut app);
assert!(!app.show_help);
app.run("find.command", None);
type_into_picker(&mut app, "app.help");
app.handle(Msg::Key(Chord::plain(Key::Enter)));
assert!(app.show_help, "accepting app.help should have run it");
}
#[test]
fn the_event_picker_needs_a_history() {
let mut app = app();
four(&mut app);
app.run("find.event", None);
assert!(app.picker.is_none());
let (msg, _) = app.note.clone().expect("should have said why");
assert!(msg.contains("no history"), "got: {msg}");
}
#[test]
fn the_pane_picker_is_not_offered_for_a_single_pane() {
let mut app = app();
four(&mut app);
app.run("find.pane", None);
assert!(app.picker.is_none());
let (msg, _) = app.note.clone().expect("should have said why");
assert!(msg.contains("only this pane"), "got: {msg}");
}
#[test]
fn the_pane_picker_lists_both_halves_of_a_split() {
let mut app = app();
four(&mut app);
app.run("window.split-right", None);
app.run("find.pane", None);
assert_eq!(picker_labels(&app).len(), 2);
}
#[test]
fn the_namespace_picker_switches_the_pane_to_the_one_chosen() {
let mut app = app();
app.handle(Msg::Namespaces(Ok(vec![
NamespaceInfo {
name: "default".into(),
state: "Registered".into(),
retention_days: 3,
description: String::new(),
},
NamespaceInfo {
name: "payments".into(),
state: "Registered".into(),
retention_days: 7,
description: String::new(),
},
])));
app.run("find.namespace", None);
type_into_picker(&mut app, "pay");
app.handle(Msg::Key(Chord::plain(Key::Enter)));
assert_eq!(app.view.scope, vec!["payments".to_string()]);
assert_eq!(app.view.screen, Screen::Workflows);
}
#[test]
fn switching_namespace_keeps_the_query() {
let mut app = app();
app.handle(Msg::Namespaces(Ok(vec![NamespaceInfo {
name: "payments".into(),
state: "Registered".into(),
retention_days: 7,
description: String::new(),
}])));
app.view.query = "ExecutionStatus = 'Running'".into();
app.run("find.namespace", None);
app.handle(Msg::Key(Chord::plain(Key::Enter)));
assert_eq!(app.view.query, "ExecutionStatus = 'Running'");
}
#[test]
fn the_problem_list_lands_in_the_query_bar_where_it_can_be_edited() {
let mut app = app();
four(&mut app);
app.run("list.problems", None);
assert!(app.view.query.contains("Failed"), "got: {}", app.view.query);
assert!(app.view.query.contains("TimedOut"));
assert!(app.view.query.contains("Terminated"));
assert_eq!(app.view.screen, Screen::Workflows);
}
#[test]
fn the_editor_is_refused_away_from_a_history() {
let mut app = app();
four(&mut app);
app.run("payload.edit", None);
assert!(app.editing.is_none());
let (msg, level) = app.note.clone().expect("should have said why");
assert_eq!(level, Note::Warn);
assert!(msg.contains("history"), "got: {msg}");
}
#[test]
fn the_editor_writes_the_payloads_and_leaves_a_request_behind() {
let mut app = app();
loaded(&mut app, vec![wf("default", "r1", 100)], vec![]);
app.run("nav.open", None);
use tmprl_core::history::{Category as C, GroupRef as G, Role as R};
let mut started = hev(1, G::Workflow, R::Opens, C::Workflow).with_subject("Order");
started.payloads.push((
"input".into(),
Payload::new("json/plain", br#"{"amount":100}"#.to_vec()),
));
app.handle(Msg::History {
generation: app.view.generation,
result: Ok((vec![started], Vec::new())),
});
app.run("payload.edit", None);
let request = app
.take_edit_request()
.expect("a request should be waiting");
let written = std::fs::read_to_string(&request.path).expect("file should exist");
assert!(written.contains("amount"), "got: {written}");
assert!(
request.what.contains("not saved back"),
"the copy must say it is a copy: {}",
request.what
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&request.path)
.unwrap()
.permissions()
.mode();
assert_eq!(
mode & 0o077,
0,
"decoded payloads must not be group/world readable"
);
}
app.finish_edit(&request, None);
assert!(
!request.path.exists(),
"the copy should have been cleaned up"
);
}
#[test]
fn an_unreadable_payload_is_reported_on_the_request_not_as_a_note() {
let mut app = app();
loaded(&mut app, vec![wf("default", "r1", 100)], vec![]);
app.run("nav.open", None);
use tmprl_core::history::{Category as C, GroupRef as G, Role as R};
let mut started = hev(1, G::Workflow, R::Opens, C::Workflow).with_subject("Order");
started.payloads.push((
"input".into(),
Payload::new("json/plain", br#"{"amount":100}"#.to_vec()),
));
started.payloads.push((
"secret".into(),
Payload::new("binary/encrypted", vec![1, 2, 3]),
));
app.handle(Msg::History {
generation: app.view.generation,
result: Ok((vec![started], Vec::new())),
});
app.run("payload.edit", None);
let request = app
.take_edit_request()
.expect("a request should be waiting");
assert!(
request.what.contains("secret"),
"the skipped payload must reach the user: {}",
request.what
);
app.finish_edit(&request, None);
}
#[test]
fn taking_the_edit_request_clears_it() {
let mut app = app();
app.editing = Some(EditRequest {
path: std::path::PathBuf::from("/tmp/tmprl-nonexistent/x.json"),
dir: std::path::PathBuf::from("/tmp/tmprl-nonexistent"),
what: "test".into(),
});
assert!(app.take_edit_request().is_some());
assert!(app.take_edit_request().is_none());
}
#[test]
fn opening_a_workflow_and_jumping_back_returns_to_the_list() {
let mut app = app();
four(&mut app);
app.run("nav.open", None);
assert_eq!(app.view.screen, Screen::History);
app.run("nav.jump-back", None);
assert_eq!(app.view.screen, Screen::Workflows);
}
#[test]
fn jumping_forward_returns_to_the_workflow() {
let mut app = app();
four(&mut app);
app.run("nav.open", None);
let opened = app.view.viewing.clone();
app.run("nav.jump-back", None);
assert_eq!(app.view.screen, Screen::Workflows);
app.run("nav.jump-forward", None);
assert_eq!(app.view.screen, Screen::History);
assert_eq!(app.view.viewing, opened);
}
#[test]
fn jumping_back_with_nowhere_to_go_says_so() {
let mut app = app();
four(&mut app);
app.run("nav.jump-back", None);
let (msg, level) = app.note.clone().expect("should have explained itself");
assert_eq!(level, Note::Warn);
assert!(msg.contains("earlier"), "got: {msg}");
}
#[test]
fn ordinary_motion_is_not_a_jump() {
let mut app = app();
four(&mut app);
app.run("motion.down", None);
app.run("motion.down", None);
app.run("nav.jump-back", None);
assert!(app.jumps.is_empty(), "j must not have recorded anything");
}
#[test]
fn gg_is_a_jump_so_you_can_get_back_from_it() {
let mut app = app();
four(&mut app);
app.run("motion.down", None);
app.run("motion.down", None);
let before = app.view.cursor;
assert_eq!(before, 2);
app.run("motion.top", None);
assert_eq!(app.view.cursor, 0);
app.run("nav.jump-back", None);
assert_eq!(app.view.cursor, before, "back to where gg was pressed from");
}
#[test]
fn a_new_jump_discards_the_forward_history() {
let mut app = app();
four(&mut app);
app.run("nav.open", None); app.run("nav.jump-back", None); assert_eq!(app.view.screen, Screen::Workflows);
app.run("motion.bottom", None);
app.run("nav.jump-forward", None);
let (msg, _) = app.note.clone().expect("should have refused");
assert!(msg.contains("later"), "got: {msg}");
}
#[test]
fn a_jump_back_to_a_workflow_list_refetches_rather_than_restoring_a_stale_one() {
let mut app = app();
four(&mut app);
app.run("nav.open", None);
let before = app.view.generation;
app.run("nav.jump-back", None);
assert_ne!(app.view.generation, before, "should have issued a fetch");
}
#[test]
fn the_problem_list_is_a_jump() {
let mut app = app();
four(&mut app);
let query = app.view.query.clone();
app.run("list.problems", None);
assert_ne!(app.view.query, query);
app.run("nav.jump-back", None);
assert_eq!(app.view.query, query, "back to the query it replaced");
}
#[test]
fn tab_jumps_forward_because_it_is_the_same_key_as_ctrl_i() {
let mut app = app();
four(&mut app);
app.run("nav.open", None);
app.run("nav.jump-back", None);
assert_eq!(app.view.screen, Screen::Workflows);
app.handle(Msg::Key(Chord::plain(Key::Tab)));
assert_eq!(app.view.screen, Screen::History, "Tab should jump forward");
}
#[test]
fn failing_to_find_a_pane_leaves_the_tab_where_it_was() {
let mut app = app();
four(&mut app);
app.run("tab.new", None);
app.run("tab.new", None);
let before = app.tabs.index();
app.focus_pane(ViewId(9999));
assert_eq!(app.tabs.index(), before, "a miss must not move the tab");
let (msg, _) = app.note.clone().expect("should have said so");
assert!(msg.contains("gone"), "got: {msg}");
}
#[test]
fn jumping_back_to_a_list_returns_to_the_workflow_not_the_row_number() {
let mut app = app();
four(&mut app);
app.run("motion.down", None);
let left = at_cursor(&app);
app.run("nav.open", None);
assert_eq!(app.view.screen, Screen::History);
app.run("nav.jump-back", None);
let mut rows = vec![wf("default", "r9", 900), wf("default", "r8", 800)];
for run in ["r4", "r3", "r2", "r1"] {
let start = 100 * run[1..].parse::<i64>().unwrap();
rows.push(wf("default", run, start));
}
app.handle(Msg::Workflows {
generation: app.view.generation,
append: false,
result: Ok((rows, vec![])),
});
assert_eq!(
at_cursor(&app),
left,
"should have followed the workflow down"
);
}
#[test]
fn picking_a_workflow_from_inside_a_history_does_not_merge_the_two() {
let mut app = app();
four(&mut app);
app.run("nav.open", None);
assert_eq!(app.view.screen, Screen::History);
app.handle(Msg::History {
generation: app.view.generation,
result: Ok((history_events(), b"page-2".to_vec())),
});
assert!(!app.view.history_events.is_empty());
assert!(!app.view.history_token.is_empty());
let first = app.view.viewing.clone().unwrap().run_id;
app.run("find.workflow", None);
type_into_picker(&mut app, "r2");
app.handle(Msg::Key(Chord::plain(Key::Enter)));
assert_ne!(app.view.viewing.clone().unwrap().run_id, first);
assert!(
app.view.history_events.is_empty(),
"the previous run's events must not survive"
);
assert!(
app.view.history_token.is_empty(),
"the previous run's page token must not be sent to this one"
);
assert!(app.view.history_resume.is_empty());
}
#[test]
fn the_problem_list_clears_the_history_it_leaves_behind() {
let mut app = app();
four(&mut app);
app.run("nav.open", None);
app.handle(Msg::History {
generation: app.view.generation,
result: Ok((history_events(), b"page-2".to_vec())),
});
app.run("list.problems", None);
assert_eq!(app.view.screen, Screen::Workflows);
assert!(app.view.history_token.is_empty(), "token must not survive");
assert!(app.view.history_resume.is_empty());
assert!(app.view.history_events.is_empty());
}
#[test]
fn a_refused_dash_does_not_destroy_the_forward_jumps() {
let mut app = app();
app.handle(Msg::Namespaces(Ok(vec![NamespaceInfo {
name: "default".into(),
state: "Registered".into(),
retention_days: 3,
description: String::new(),
}])));
app.view.screen = Screen::Namespaces;
app.run("nav.open", None); four(&mut app);
app.run("nav.open", None); assert_eq!(app.view.screen, Screen::History);
app.run("nav.jump-back", None); app.run("nav.jump-back", None); assert_eq!(app.view.screen, Screen::Namespaces);
let ahead = app.jumps.len();
app.run("nav.up", None); assert_eq!(
app.jumps.len(),
ahead,
"a refused move must not touch the jumplist"
);
app.run("nav.jump-forward", None);
assert_eq!(
app.view.screen,
Screen::Workflows,
"the forward list should still lead back down"
);
}
#[test]
fn a_first_search_from_the_top_does_not_claim_to_have_wrapped() {
let mut app = app();
four(&mut app);
assert_eq!(app.view.cursor, 0);
search_for(&mut app, "refund");
let (msg, _) = app.note.clone().expect("a search reports itself");
assert!(!msg.contains("wrapped"), "got: {msg}");
}
#[test]
fn a_search_that_matches_nothing_does_not_record_a_jump() {
let mut app = app();
four(&mut app);
app.run("nav.open", None); app.run("nav.jump-back", None);
search_for(&mut app, "nothing-matches-this");
app.run("nav.jump-forward", None);
assert_eq!(
app.view.screen,
Screen::History,
"a failed search must not have truncated the forward list"
);
}
#[test]
fn the_command_picker_finds_a_command_by_its_title() {
let mut app = app();
four(&mut app);
app.run("find.command", None);
type_into_picker(&mut app, "failed");
let shown = picker_labels(&app);
assert!(
shown.iter().any(|l| l.starts_with("list.problems")),
"got {shown:?}"
);
}
#[test]
fn json_strings_escape_control_characters() {
assert_eq!(json_string(r#"a"b"#), r#""a\"b""#);
assert_eq!(json_string("a\nb"), r#""a\nb""#);
assert_eq!(json_string("a\u{1}b"), r#""a\u0001b""#);
}
#[test]
fn the_cursor_stays_on_its_workflow_when_a_newer_one_arrives() {
let mut app = app();
loaded(&mut app, vec![wf("default", "r1", 100)], vec![]);
app.run("motion.top", None);
assert_eq!(app.workflow_rows()[app.view.cursor].run_id, "r1");
app.handle(Msg::Workflows {
generation: app.view.generation,
append: false,
result: Ok((
vec![wf("default", "r9", 900), wf("default", "r1", 100)],
vec![],
)),
});
assert_eq!(
app.view.cursor, 1,
"cursor should have followed r1 down a row"
);
assert_eq!(app.workflow_rows()[app.view.cursor].run_id, "r1");
}
#[test]
fn a_reply_for_a_superseded_query_is_dropped() {
let mut app = app();
loaded(&mut app, vec![wf("default", "old", 100)], vec![]);
let stale = app.view.generation;
app.view.query = "WorkflowType = 'New'".into();
app.load_workflows(false);
assert_ne!(app.view.generation, stale);
app.handle(Msg::Workflows {
generation: stale,
append: false,
result: Ok((vec![wf("default", "stale", 1)], vec![])),
});
assert_eq!(
app.workflow_rows()[0].run_id,
"old",
"a stale reply must not land"
);
}
#[test]
fn a_failed_extra_page_keeps_the_rows_already_on_screen() {
let mut app = app();
loaded(
&mut app,
vec![wf("default", "r1", 100)],
vec![("default".into(), vec![1])],
);
app.handle(Msg::Workflows {
generation: app.view.generation,
append: true,
result: Err("connection reset".into()),
});
assert_eq!(app.workflow_rows().len(), 1, "rows must survive");
assert!(matches!(app.note, Some((_, Note::Error))));
}
#[test]
fn a_failed_first_page_shows_the_error_state() {
let mut app = app();
app.view.screen = Screen::Workflows;
app.handle(Msg::Workflows {
generation: app.view.generation,
append: false,
result: Err("permission denied".into()),
});
assert_eq!(app.view.workflows.error(), Some("permission denied"));
}
#[test]
fn enter_opens_a_namespace_and_dash_goes_back() {
let mut app = app();
app.view.namespaces = Loadable::loaded(vec![
NamespaceInfo {
name: "alpha".into(),
state: "Registered".into(),
retention_days: 1,
description: String::new(),
},
NamespaceInfo {
name: "beta".into(),
state: "Registered".into(),
retention_days: 1,
description: String::new(),
},
]);
app.run("motion.bottom", None);
app.run("nav.open", None);
assert_eq!(app.view.screen, Screen::Workflows);
assert_eq!(
app.view.scope,
["beta"],
"the focused namespace becomes the scope"
);
app.run("nav.up", None);
assert_eq!(app.view.screen, Screen::Namespaces);
assert_eq!(app.view.cursor, 1, "the namespace cursor is restored");
}
#[test]
fn a_visual_selection_of_namespaces_opens_a_fan_out() {
let mut app = app();
app.view.namespaces = Loadable::loaded(
["alpha", "beta", "gamma"]
.iter()
.map(|n| NamespaceInfo {
name: (*n).into(),
state: "Registered".into(),
retention_days: 1,
description: String::new(),
})
.collect(),
);
for chord in [
Chord::ch('g'),
Chord::ch('g'),
Chord::ch('V'),
Chord::ch('j'),
Chord::plain(Key::Enter),
] {
app.handle(Msg::Key(chord));
}
assert_eq!(app.view.scope, ["alpha", "beta"]);
assert!(
app.view.is_fanned_out(),
"rows must be tagged with their namespace"
);
assert_eq!(app.mode, Mode::Normal, "opening ends the selection");
assert!(app.view.anchor.is_none());
}
#[test]
fn opening_without_a_selection_scopes_to_one_namespace() {
let mut app = app();
app.view.namespaces = Loadable::loaded(vec![NamespaceInfo {
name: "alpha".into(),
state: "Registered".into(),
retention_days: 1,
description: String::new(),
}]);
app.run("nav.open", None);
assert_eq!(app.view.scope, ["alpha"]);
assert!(!app.view.is_fanned_out());
}
#[test]
fn insert_mode_edits_the_query_on_the_workflow_screen() {
let mut app = app();
loaded(&mut app, vec![], vec![]);
app.view.query = "A = 1".into();
app.run("mode.insert", None);
assert!(app.is_editing_query());
assert_eq!(app.insert_buf, "A = 1", "the edit starts from the query");
app.handle(Msg::Key(Chord::plain(Key::Backspace)));
type_chars(&mut app, "2");
assert_eq!(app.query_display(), "A = 2");
app.handle(Msg::Key(Chord::plain(Key::Enter)));
assert_eq!(app.view.query, "A = 2", "Enter applies the query");
assert_eq!(app.mode, Mode::Normal);
}
#[test]
fn escape_abandons_a_query_edit() {
let mut app = app();
loaded(&mut app, vec![], vec![]);
app.view.query = "A = 1".into();
app.run("mode.insert", None);
type_chars(&mut app, "999");
app.handle(Msg::Key(Chord::plain(Key::Esc)));
assert_eq!(app.view.query, "A = 1", "Esc must not apply the edit");
assert_eq!(app.query_display(), "A = 1");
}
#[test]
fn insert_mode_on_the_namespace_screen_is_not_the_query_bar() {
let mut app = app();
app.run("mode.insert", None);
assert!(!app.is_editing_query());
type_chars(&mut app, "xy");
assert_eq!(app.insert_buf, "xy");
assert_eq!(app.view.query, "", "the namespace screen has no query bar");
}
#[test]
fn a_saved_view_fills_the_query_bar_and_leaves_it_editable() {
let mut app = app();
let views = vec![SavedView {
key: '1',
name: "Broken".into(),
query: "ExecutionStatus = 'Failed'".into(),
}];
app.registry.add_views(&views);
app.views = views;
app.run("view.1", None);
assert_eq!(app.view.query, "ExecutionStatus = 'Failed'");
assert_eq!(app.view.screen, Screen::Workflows);
app.run("mode.insert", None);
assert_eq!(app.insert_buf, "ExecutionStatus = 'Failed'");
}
#[test]
fn scrolling_near_the_end_asks_for_the_next_page_once() {
let mut app = app();
app.view.page = 2;
let rows: Vec<WorkflowRow> = (0..10)
.map(|i| wf("default", &format!("r{i}"), 1000 - i))
.collect();
loaded(&mut app, rows, vec![("default".into(), vec![7])]);
assert!(
!app.view.loading_more,
"a completed load clears the in-flight flag"
);
app.run("motion.bottom", None);
assert!(
app.view.loading_more,
"reaching the end should request the next page"
);
app.run("motion.up", None);
app.run("motion.bottom", None);
assert!(app.view.loading_more);
}
#[test]
fn scrolling_does_not_page_when_the_list_is_complete() {
let mut app = app();
app.view.page = 2;
let rows: Vec<WorkflowRow> = (0..5)
.map(|i| wf("default", &format!("r{i}"), 1000 - i))
.collect();
loaded(&mut app, rows, vec![]);
app.run("motion.bottom", None);
assert!(
!app.view.loading_more,
"no token means nothing left to fetch"
);
}
#[test]
fn yank_on_a_workflow_row_takes_the_workflow_id() {
let mut app = app();
loaded(&mut app, vec![wf("default", "r1", 100)], vec![]);
assert_eq!(app.field_under_cursor(), "order-r1");
let record = app.records_selected();
assert!(
record.contains(r#""workflowId":"order-r1""#),
"got {record}"
);
assert!(record.contains(r#""status":"Running""#), "got {record}");
assert!(record.contains(r#""namespace":"default""#), "got {record}");
}
#[test]
fn a_visual_selection_yanks_every_selected_workflow() {
let mut app = app();
loaded(
&mut app,
vec![wf("default", "r1", 300), wf("default", "r2", 200)],
vec![],
);
app.run("motion.top", None);
app.run("mode.visual", None);
app.run("motion.down", None);
let record = app.records_selected();
assert!(record.starts_with('['), "a multi-row yank is an array");
assert!(record.contains("order-r1") && record.contains("order-r2"));
}
fn hev(
id: i64,
group: tmprl_core::history::GroupRef,
role: tmprl_core::history::Role,
cat: tmprl_core::history::Category,
) -> NormalizedEvent {
NormalizedEvent::new(id, "E", cat, group, role).with_time(Some(id * 1000))
}
fn history_events() -> Vec<NormalizedEvent> {
use tmprl_core::history::{Category as C, GroupRef as G, Role as R};
vec![
hev(1, G::Workflow, R::Opens, C::Workflow).with_subject("Order"),
hev(2, G::Opened(2), R::Opens, C::WorkflowTask),
hev(3, G::Opened(2), R::Closes, C::WorkflowTask),
hev(4, G::Opened(4), R::Opens, C::Activity).with_subject("Charge"),
hev(5, G::Opened(4), R::Continues, C::Activity),
hev(6, G::Opened(4), R::Closes, C::Activity)
.with_outcome(tmprl_core::history::Outcome::Completed),
hev(7, G::Opened(7), R::Opens, C::Activity).with_subject("Ship"),
hev(8, G::Opened(7), R::Closes, C::Activity)
.with_outcome(tmprl_core::history::Outcome::Failed),
]
}
fn viewing_history() -> App {
let mut app = app();
loaded(&mut app, vec![wf("default", "r1", 100)], vec![]);
app.run("nav.open", None);
assert_eq!(app.view.screen, Screen::History);
app.handle(Msg::History {
generation: app.view.generation,
result: Ok((history_events(), Vec::new())),
});
app
}
#[test]
fn opening_a_workflow_reads_its_history() {
let app = viewing_history();
assert_eq!(app.view.viewing.as_ref().unwrap().run_id, "r1");
assert_eq!(app.row_count(), 3);
}
#[test]
fn dash_returns_to_the_workflow_it_came_from() {
let mut app = viewing_history();
app.run("nav.up", None);
assert_eq!(app.view.screen, Screen::Workflows);
assert!(app.view.viewing.is_none());
assert!(
app.view.history.value().is_none(),
"leaving must drop the history rather than show a stale one on re-entry"
);
}
#[test]
fn folding_a_group_shows_its_events_and_keeps_the_cursor_on_it() {
let mut app = viewing_history();
app.run("motion.down", None); let before = app.row_count();
let at = app.view.cursor;
app.run("history.fold", None);
assert_eq!(app.row_count(), before + 3, "its three events appeared");
assert_eq!(
app.view.cursor, at,
"the cursor stays on the group's own line"
);
app.run("motion.down", None);
app.run("motion.down", None);
app.run("history.fold", None);
assert_eq!(app.row_count(), before);
assert_eq!(app.view.cursor, at);
}
#[test]
fn expanding_everything_keeps_the_cursor_on_the_same_group() {
let mut app = viewing_history();
app.run("motion.bottom", None); let group = app.group_under_cursor();
app.run("history.expand-all", None);
assert_eq!(
app.group_under_cursor(),
group,
"expanding moves every row; the cursor must follow its group"
);
app.run("history.collapse-all", None);
assert_eq!(app.group_under_cursor(), group);
}
#[test]
fn workflow_tasks_are_hidden_until_asked_for() {
let mut app = viewing_history();
assert_eq!(app.row_count(), 3);
app.run("history.plumbing", None);
assert_eq!(app.row_count(), 4, "the workflow-task group appeared");
assert!(matches!(app.note, Some((_, Note::Info))));
app.run("history.plumbing", None);
assert_eq!(app.row_count(), 3);
}
#[test]
fn failures_are_reachable_by_key() {
let mut app = viewing_history();
app.run("motion.top", None);
app.run("history.next-failure", None);
let group = app.group_under_cursor().expect("on a group");
let outline = app.view.history.value().unwrap();
assert_eq!(outline.group(group).unwrap().subject, "Ship");
app.run("history.next-failure", None);
assert!(matches!(app.note, Some((_, Note::Warn))));
}
#[test]
fn a_second_history_page_is_appended_and_regrouped() {
let mut app = app();
loaded(&mut app, vec![wf("default", "r1", 100)], vec![]);
app.run("nav.open", None);
app.handle(Msg::History {
generation: app.view.generation,
result: Ok((history_events()[..5].to_vec(), vec![7])),
});
let charge = app.view.history.value().unwrap().group(2).unwrap().clone();
assert!(charge.is_open(), "the group is incomplete on page one");
app.handle(Msg::History {
generation: app.view.generation,
result: Ok((history_events()[5..].to_vec(), Vec::new())),
});
let charge = app.view.history.value().unwrap().group(2).unwrap();
assert!(!charge.is_open(), "the second page closed the group");
assert_eq!(app.row_count(), 3);
}
#[test]
fn a_stale_history_reply_is_dropped() {
let mut app = viewing_history();
let stale = app.view.generation;
app.view.generation = app.view.generation.wrapping_add(1);
app.handle(Msg::History {
generation: stale,
result: Ok((Vec::new(), Vec::new())),
});
assert_eq!(
app.row_count(),
3,
"a reply for an abandoned read must not land"
);
}
#[test]
fn yanking_a_history_row_takes_something_useful() {
let mut app = viewing_history();
app.run("motion.bottom", None);
assert_eq!(app.field_under_cursor(), "Ship");
let record = app.records_selected();
assert!(record.contains(r#""group":"Ship""#), "got {record}");
assert!(record.contains(r#""outcome":"Failed""#), "got {record}");
}
fn running_history() -> Vec<NormalizedEvent> {
use tmprl_core::history::{Category as C, GroupRef as G, Role as R};
vec![
hev(1, G::Workflow, R::Opens, C::Workflow).with_subject("Order"),
hev(4, G::Opened(4), R::Opens, C::Activity).with_subject("Charge"),
]
}
fn viewing_running() -> App {
let mut app = app();
loaded(&mut app, vec![wf("default", "r1", 100)], vec![]);
app.run("nav.open", None);
app.handle(Msg::History {
generation: app.view.generation,
result: Ok((running_history(), vec![9])),
});
app
}
#[test]
fn follow_starts_and_stops_on_the_same_key() {
let mut app = viewing_running();
assert!(!app.view.following);
app.run("history.follow", None);
assert!(app.view.following, "F should start following");
assert!(matches!(app.note, Some((_, Note::Info))));
app.run("history.follow", None);
assert!(!app.view.following, "F again should stop");
}
#[test]
fn follow_refuses_on_a_workflow_that_has_already_closed() {
use tmprl_core::history::{Category as C, GroupRef as G, Outcome as O, Role as R};
let mut app = app();
loaded(&mut app, vec![wf("default", "r1", 100)], vec![]);
app.run("nav.open", None);
let mut events = history_events();
events.push(hev(9, G::Workflow, R::Closes, C::Workflow).with_outcome(O::Completed));
app.handle(Msg::History {
generation: app.view.generation,
result: Ok((events, Vec::new())),
});
assert!(app.view.history_token.is_empty());
app.run("history.follow", None);
assert!(!app.view.following, "there is nothing to follow");
let (msg, kind) = app.note.clone().unwrap();
assert_eq!(kind, Note::Warn);
assert!(msg.contains("closed"), "got {msg}");
}
#[test]
fn follow_is_not_offered_away_from_a_history() {
let mut app = app();
loaded(&mut app, vec![wf("default", "r1", 100)], vec![]);
app.run("history.follow", None);
assert!(!app.view.following);
assert!(matches!(app.note, Some((_, Note::Warn))));
}
#[test]
fn an_empty_token_while_following_means_the_workflow_closed() {
let mut app = viewing_running();
app.run("history.follow", None);
assert!(app.view.following);
use tmprl_core::history::{Category as C, GroupRef as G, Outcome as O, Role as R};
app.handle(Msg::History {
generation: app.view.generation,
result: Ok((
vec![hev(9, G::Workflow, R::Closes, C::Workflow).with_outcome(O::Completed)],
Vec::new(),
)),
});
assert!(
!app.view.following,
"follow must stop when the workflow closes"
);
let (msg, _) = app.note.clone().unwrap();
assert!(msg.contains("closed"), "got {msg}");
}
#[test]
fn replayed_events_do_not_duplicate_when_follow_resumes() {
let mut app = viewing_running();
let before = app.view.history_events.len();
app.handle(Msg::History {
generation: app.view.generation,
result: Ok((running_history(), vec![9])),
});
assert_eq!(
app.view.history_events.len(),
before,
"a replayed page must not be appended twice"
);
}
#[test]
fn the_resume_token_is_the_last_non_empty_one() {
let mut app = viewing_running();
assert_eq!(app.view.history_resume, vec![9]);
app.handle(Msg::History {
generation: app.view.generation,
result: Ok((Vec::new(), Vec::new())),
});
assert!(app.view.history_token.is_empty(), "caught up");
assert_eq!(
app.view.history_resume,
vec![9],
"the resume point is remembered"
);
}
#[test]
fn leaving_the_history_stops_following() {
let mut app = viewing_running();
app.run("history.follow", None);
assert!(app.view.following);
app.run("nav.up", None);
assert!(
!app.view.following,
"a poll must not outlive the screen it feeds"
);
assert!(app.view.history_resume.is_empty());
}
#[test]
fn yanking_the_result_unwraps_it() {
let mut app = viewing_payloads();
app.run("motion.down", None); app.run("yank.payload-result", None);
let (note, level) = app.note.clone().expect("a yank should report");
assert!(note.contains("yanked"), "{note}");
assert!(matches!(level, Note::Info));
}
#[test]
fn yanking_the_input_skips_the_result() {
let mut app = viewing_payloads();
app.run("motion.down", None); let all = app.payloads_under_cursor();
assert!(
all.iter().any(|(l, _)| l == "input") && all.iter().any(|(l, _)| l == "result"),
"fixture should carry both"
);
app.run("yank.payload-input", None);
assert!(app.note.as_ref().unwrap().0.contains("yanked"));
}
#[test]
fn yanking_a_payload_off_a_history_is_refused() {
let mut app = app();
app.run("yank.payload", None);
let (note, level) = app.note.clone().expect("a refusal should be reported");
assert!(note.contains("workflow history"), "{note}");
assert!(matches!(level, Note::Warn));
}
#[test]
fn yanking_an_absent_part_says_so() {
let mut app = viewing_payloads();
app.run("motion.top", None);
app.run("yank.payload-result", None);
let (note, level) = app.note.clone().unwrap();
assert!(note.contains("no result"), "got {note}");
assert_eq!(level, Note::Warn);
}
fn viewing_payloads() -> App {
use tmprl_core::history::{Category as C, GroupRef as G, Outcome as O, Role as R};
let mut scheduled = hev(4, G::Opened(4), R::Opens, C::Activity).with_subject("Charge");
scheduled.payloads.push((
"input".into(),
Payload::new("json/plain", br#"{"amount":100}"#.to_vec()),
));
let mut completed = hev(6, G::Opened(4), R::Closes, C::Activity).with_outcome(O::Completed);
completed.payloads.push((
"result".into(),
Payload::new("json/plain", b"\"charged\"".to_vec()),
));
let mut secret = hev(7, G::Opened(7), R::Opens, C::Activity).with_subject("Secret");
secret.payloads.push((
"input".into(),
Payload::new("binary/encrypted", vec![0u8; 16]),
));
let mut app = app();
loaded(&mut app, vec![wf("default", "r1", 100)], vec![]);
app.run("nav.open", None);
app.handle(Msg::History {
generation: app.view.generation,
result: Ok((
vec![
hev(1, G::Workflow, R::Opens, C::Workflow).with_subject("Order"),
scheduled,
completed,
secret,
],
Vec::new(),
)),
});
app
}
#[test]
fn the_pipe_prompt_gathers_a_group_s_input_and_result() {
let mut app = viewing_payloads();
app.run("motion.down", None); let payloads = app.payloads_under_cursor();
let labels: Vec<&str> = payloads.iter().map(|(l, _)| l.as_str()).collect();
assert_eq!(
labels,
["input", "result"],
"a group's arguments and its result live on two different events"
);
}
#[test]
fn the_pipe_prompt_opens_prefilled_with_jq() {
let mut app = viewing_payloads();
app.run("motion.down", None);
app.run("payload.pipe", None);
let p = app.prompt.clone().expect("a prompt should open");
assert_eq!(p.kind, PromptKind::Pipe);
assert_eq!(
p.buf, "jq .",
"an empty prompt means retyping jq every time"
);
assert_eq!(p.sigil(), "!");
}
#[test]
fn piping_is_refused_when_nothing_readable_is_under_the_cursor() {
let mut app = viewing_payloads();
app.run("motion.bottom", None); app.run("payload.pipe", None);
assert!(app.prompt.is_none(), "there is nothing worth piping");
let (msg, kind) = app.note.clone().unwrap();
assert_eq!(kind, Note::Warn);
assert!(
msg.contains("encrypted"),
"the reason should be given: {msg}"
);
}
#[test]
fn piping_is_refused_away_from_a_history() {
let mut app = app();
loaded(&mut app, vec![wf("default", "r1", 100)], vec![]);
app.run("payload.pipe", None);
assert!(app.prompt.is_none());
assert!(matches!(app.note, Some((_, Note::Warn))));
}
#[test]
fn a_filter_result_is_dropped_when_the_cursor_moves() {
let mut app = viewing_payloads();
app.run("motion.down", None);
app.view.piped = Some(Ok("{}".into()));
app.run("motion.down", None);
assert!(app.view.piped.is_none());
}
#[test]
fn a_pipe_result_message_opens_the_pane_and_lands() {
let mut app = viewing_payloads();
app.handle(Msg::Piped(Ok("42\n".into())));
assert_eq!(app.view.piped, Some(Ok("42\n".into())));
assert_eq!(app.view.detail_scroll, 0);
}
#[test]
fn both_prompts_edit_the_same_way() {
use tmprl_core::Key;
for open in ["app.command-line", "payload.pipe"] {
let mut app = viewing_payloads();
app.run("motion.down", None);
app.run(open, None);
let start = app.prompt.clone().unwrap().buf.len();
app.handle(Msg::Key(Chord::ch('x')));
assert_eq!(app.prompt.clone().unwrap().buf.len(), start + 1, "{open}");
app.handle(Msg::Key(Chord::plain(Key::Backspace)));
assert_eq!(app.prompt.clone().unwrap().buf.len(), start, "{open}");
app.handle(Msg::Key(Chord::plain(Key::Esc)));
assert!(app.prompt.is_none(), "{open}: Esc should close");
assert_eq!(app.mode, Mode::Normal, "{open}");
}
}
#[test]
fn backspace_on_an_empty_prompt_closes_it() {
use tmprl_core::Key;
let mut app = viewing_payloads();
app.run("app.command-line", None);
app.handle(Msg::Key(Chord::plain(Key::Backspace)));
assert!(app.prompt.is_none(), "as it does in vim");
}
#[tokio::test]
async fn a_filter_receives_the_payloads_on_stdin() {
let out = pipe_through("cat", br#"{"a":1}"#.to_vec()).await.unwrap();
assert_eq!(out, r#"{"a":1}"#);
}
#[tokio::test]
async fn a_failing_filter_reports_the_command_s_own_stderr() {
let err = pipe_through("echo 'boom' >&2; exit 3", Vec::new())
.await
.unwrap_err();
assert!(err.contains("boom"), "got {err:?}");
}
#[tokio::test]
async fn a_filter_that_exits_silently_still_reports_failure() {
let err = pipe_through("exit 1", Vec::new()).await.unwrap_err();
assert!(err.contains("exited"), "got {err:?}");
}
#[tokio::test]
async fn a_filter_that_ignores_its_input_does_not_error() {
let out = pipe_through("echo done", vec![b'x'; 1_000_000])
.await
.unwrap();
assert_eq!(out.trim(), "done");
}
#[test]
fn a_decoded_payload_replaces_the_encrypted_one_everywhere() {
let mut app = viewing_payloads();
app.run("motion.bottom", None);
let encrypted = app
.payloads_under_cursor()
.into_iter()
.next()
.map(|(_, p)| p)
.expect("an encrypted payload");
assert!(encrypted.needs_codec());
let key = App::payload_key(&encrypted);
app.handle(Msg::Decoded(Ok(vec![(
key,
Payload::new("json/plain", br#"{"secret":true}"#.to_vec()),
)])));
let (_, now) = app
.payloads_under_cursor()
.into_iter()
.next()
.expect("still a payload");
assert!(!now.needs_codec(), "it should be plaintext now");
assert_eq!(
now.render(),
tmprl_core::payload::Rendered::Text("{\n \"secret\": true\n}".into())
);
assert!(now.pipeable().is_some());
}
#[test]
fn a_decode_failure_is_reported_and_can_be_retried() {
let mut app = viewing_payloads();
app.decoding.insert(42);
app.handle(Msg::Decoded(Err("codec server returned 502".into())));
let (msg, kind) = app.note.clone().unwrap();
assert_eq!(kind, Note::Error);
assert!(msg.contains("502"), "the server's own words: {msg}");
assert!(app.decoding.is_empty(), "a retry must be possible");
}
#[test]
fn a_failed_decode_is_remembered_rather_than_flashed() {
let mut app = viewing_payloads();
let p = Payload::new("binary/aes_comp", vec![1, 2, 3]);
app.codec = Some(Arc::new(Codec::new("http://127.0.0.1:1", None)));
app.decoding.insert(App::payload_key(&p));
app.handle(Msg::Decoded(Err("connection refused".into())));
match app.decode_state(&p) {
DecodeState::Failed(why) => assert!(why.contains("connection refused"), "{why}"),
other => panic!("expected a recorded failure, got {other:?}"),
}
app.run("app.refresh", None);
assert_eq!(app.decode_state(&p), DecodeState::Idle);
}
#[test]
fn the_same_ciphertext_is_only_decoded_once() {
let a = Payload::new("binary/encrypted", vec![1, 2, 3]);
let b = Payload::new("binary/encrypted", vec![1, 2, 3]);
let c = Payload::new("binary/encrypted", vec![9, 9, 9]);
assert_eq!(App::payload_key(&a), App::payload_key(&b));
assert_ne!(App::payload_key(&a), App::payload_key(&c));
}
#[test]
fn a_payload_key_distinguishes_encodings_with_identical_bytes() {
let a = Payload::new("binary/encrypted", vec![1, 2, 3]);
let b = Payload::new("binary/plain", vec![1, 2, 3]);
assert_ne!(App::payload_key(&a), App::payload_key(&b));
}
#[test]
fn nothing_is_decoded_without_a_configured_codec() {
let mut app = viewing_payloads();
app.run("motion.bottom", None);
app.run("history.detail", None);
assert!(app.decoding.is_empty(), "there is nowhere to send it");
}
#[test]
fn a_config_without_a_codec_section_is_not_an_error() {
let mut app = app();
app.apply_config(None, None, Some("# nothing here\n"));
assert!(app.note.is_none());
assert!(app.codec.is_none());
}
#[test]
fn a_broken_config_is_surfaced() {
let mut app = app();
app.apply_config(None, None, Some("[codec]\nauth = \"x\"\n"));
let (msg, kind) = app.note.clone().unwrap();
assert_eq!(kind, Note::Error);
assert!(msg.contains("codec.endpoint"), "got {msg}");
}
#[test]
fn a_configured_codec_is_used() {
let mut app = app();
app.apply_config(
None,
None,
Some("[codec]\nendpoint = \"http://localhost:8081\"\n"),
);
assert!(app.note.is_none());
assert!(app.codec.is_some());
}
#[test]
fn splitting_keeps_the_old_pane_and_focuses_a_fresh_one() {
let mut app = app();
app.view.namespaces = Loadable::loaded(vec![NamespaceInfo {
name: "alpha".into(),
state: "Registered".into(),
retention_days: 1,
description: String::new(),
}]);
app.run("window.split-right", None);
assert_eq!(app.tabs.current().len(), 2);
assert_eq!(app.view.namespace_rows().len(), 0);
let others: Vec<_> = app
.tabs
.current()
.views()
.into_iter()
.filter_map(|id| app.parked_view(id))
.collect();
assert_eq!(others.len(), 1, "exactly one pane is parked");
assert_eq!(
others[0].namespace_rows().len(),
1,
"the original pane kept its namespaces"
);
}
#[test]
fn a_new_pane_opens_where_you_split_from() {
let mut app = app();
app.view.screen = Screen::Workflows;
app.view.query = "ExecutionStatus = 'Failed'".into();
app.view.scope = vec!["payments".into()];
app.run("window.split-right", None);
assert_eq!(app.view.screen, Screen::Workflows);
assert_eq!(app.view.query, "ExecutionStatus = 'Failed'");
assert_eq!(app.view.scope, ["payments"]);
assert!(app.view.workflows.value().is_none());
}
#[test]
fn focus_moves_between_panes_and_carries_their_state() {
let mut app = app();
app.view.query = "left".into();
app.run("window.split-right", None);
app.view.query = "right".into();
app.run("window.focus-left", None);
assert_eq!(app.view.query, "left", "each pane keeps its own query");
app.run("window.focus-right", None);
assert_eq!(app.view.query, "right");
}
#[test]
fn closing_a_window_leaves_the_survivor_focused_with_its_own_state() {
let mut app = app();
app.view.query = "kept".into();
app.run("window.split-right", None);
app.view.query = "doomed".into();
app.run("window.close", None);
assert_eq!(app.tabs.current().len(), 1);
assert_eq!(app.view.query, "kept");
}
#[test]
fn the_last_window_refuses_to_close_and_says_how_to_quit() {
let mut app = app();
app.run("window.close", None);
assert_eq!(app.tabs.current().len(), 1);
let (msg, kind) = app.note.clone().unwrap();
assert_eq!(kind, Note::Warn);
assert!(msg.contains("quit"), "should point at the way out: {msg}");
}
#[test]
fn tabs_keep_separate_windows_and_state() {
let mut app = app();
app.view.query = "first tab".into();
app.run("window.split-right", None);
assert_eq!(app.tabs.current().len(), 2);
app.run("tab.new", None);
assert_eq!(app.tabs.len(), 2);
assert_eq!(app.tabs.current().len(), 1, "a new tab has one window");
assert_eq!(app.view.query, "", "and a fresh view");
app.run("tab.previous", None);
assert_eq!(app.tabs.current().len(), 2, "the split is still there");
assert_eq!(app.view.query, "first tab");
}
#[test]
fn the_last_tab_refuses_to_close() {
let mut app = app();
app.run("tab.close", None);
assert_eq!(app.tabs.len(), 1);
assert!(matches!(app.note, Some((_, Note::Warn))));
}
#[test]
fn a_closed_window_stops_the_poll_it_was_running() {
let mut app = app();
app.run("window.split-right", None);
app.view.following = true;
app.run("window.close", None);
assert!(!app.view.following, "the survivor was never following");
assert_eq!(app.tabs.current().len(), 1);
}
fn on_a_workflow() -> App {
let mut app = app();
loaded(&mut app, vec![wf("default", "r1", 100)], vec![]);
app
}
#[test]
fn a_mutation_key_only_opens_a_confirmation() {
let mut app = on_a_workflow();
app.run("workflow.terminate", None);
let c = app.confirm.clone().expect("a confirmation should open");
assert_eq!(c.first().verb(), "Terminate");
assert_eq!(c.first().workflow_id(), "order-r1");
assert_eq!(c.first().namespace(), "default");
}
#[test]
fn a_key_that_cannot_list_namespaces_still_lands_somewhere_usable() {
let mut app = App::detached("sit", "lora-sit.ixing", unbounded_channel().0);
app.handle(Msg::Namespaces(Err(
"code: 'The caller does not have permission to execute the specified operation', \
message: \"Request unauthorized.\""
.into(),
)));
let rows = app.namespace_rows();
assert_eq!(rows.len(), 1, "the profile's own namespace is the fallback");
assert_eq!(rows[0].name, "lora-sit.ixing");
let (note, level) = app.note.clone().expect("the reader must be told why");
assert!(note.contains("cannot list namespaces"), "{note}");
assert!(note.contains("lora-sit.ixing"), "{note}");
assert_eq!(
level,
Note::Info,
"this is not an error, it is a scoped key"
);
}
#[test]
fn a_real_namespace_failure_is_still_an_error() {
let mut app = App::detached("sit", "lora-sit.ixing", unbounded_channel().0);
app.handle(Msg::Namespaces(Err("transport error".into())));
assert!(app.namespace_rows().is_empty());
assert_eq!(app.note.clone().unwrap().1, Note::Error);
}
#[test]
fn a_readonly_profile_refuses_before_a_confirmation_opens() {
let mut app = on_a_workflow();
app.apply_config(None, None, Some("[profile.prod]\nreadonly = true"));
assert!(
app.readonly(),
"config.toml should have marked prod read-only"
);
app.run("workflow.terminate", None);
assert!(app.confirm.is_none(), "no confirmation may open");
let (text, level) = app.note.clone().expect("a refusal should be reported");
assert!(
text.contains("prod"),
"the refusal names the profile: {text}"
);
assert!(text.contains("read-only"), "{text}");
assert!(matches!(level, Note::Warn));
}
#[test]
fn a_readonly_profile_refuses_at_the_wire_too() {
let mut app = on_a_workflow();
app.apply_config(None, None, Some("[profile.prod]\nreadonly = true"));
app.run_mutations(vec![Mutation::Terminate {
namespace: "default".into(),
workflow_id: "order-r1".into(),
run_id: "r1".into(),
reason: "x".into(),
}]);
let (text, _) = app.note.clone().expect("a refusal should be reported");
assert!(text.contains("read-only"), "{text}");
}
#[test]
fn a_profile_without_a_readonly_flag_still_mutates() {
let mut app = on_a_workflow();
app.apply_config(None, None, Some("[profile.sit]\nreadonly = true"));
assert!(
!app.readonly(),
"another profile's flag must not apply here"
);
app.run("workflow.terminate", None);
assert!(app.confirm.is_some(), "a confirmation should still open");
}
#[test]
fn a_confirmation_owns_every_key_while_it_is_up() {
let mut app = on_a_workflow();
let before = app.view.cursor;
app.run("workflow.terminate", None);
app.handle(Msg::Key(Chord::ch('j')));
assert_eq!(app.view.cursor, before, "j must not move the cursor");
assert!(
app.confirm.is_some(),
"and must not dismiss the confirmation"
);
app.handle(Msg::Key(Chord::ch(' ')));
assert!(
app.which_key.is_empty(),
"the leader must not open which-key"
);
}
#[test]
fn escape_always_backs_out() {
let mut app = on_a_workflow();
app.run("workflow.terminate", None);
app.handle(Msg::Key(Chord::plain(tmprl_core::Key::Esc)));
assert!(app.confirm.is_none());
let (msg, _) = app.note.clone().unwrap();
assert_eq!(msg, "cancelled");
}
#[test]
fn deleting_costs_a_word_and_nearly_is_not_enough() {
let mut app = on_a_workflow();
app.run("workflow.delete", None);
let c = app.confirm.clone().unwrap();
assert_eq!(c.typed_word.as_deref(), Some("delete"));
for ch in "delet".chars() {
app.handle(Msg::Key(Chord::ch(ch)));
}
app.handle(Msg::Key(Chord::plain(tmprl_core::Key::Enter)));
assert!(app.confirm.is_some(), "still waiting for the word");
app.handle(Msg::Key(Chord::ch('e')));
assert!(app.confirm.clone().unwrap().is_satisfied());
}
#[test]
fn a_mutation_needs_something_under_the_cursor() {
let mut app = app(); app.run("workflow.terminate", None);
assert!(app.confirm.is_none());
assert!(matches!(app.note, Some((_, Note::Warn))));
}
#[test]
fn a_history_screen_mutates_the_workflow_it_is_showing() {
let mut app = on_a_workflow();
app.run("nav.open", None);
assert_eq!(app.view.screen, Screen::History);
app.run("workflow.cancel", None);
let c = app
.confirm
.clone()
.expect("the open workflow is the target");
assert_eq!(c.first().workflow_id(), "order-r1");
}
fn on_four_workflows() -> App {
let mut app = app();
loaded(
&mut app,
vec![
wf("default", "r1", 400),
wf("default", "r2", 300),
wf("default", "r3", 200),
wf("default", "r4", 100),
],
vec![],
);
app
}
fn select(app: &mut App, count: usize) {
app.run("mode.visual-line", None);
for _ in 1..count {
app.handle(Msg::Key(Chord::ch('j')));
}
}
#[test]
fn a_mutation_over_a_selection_covers_every_selected_row() {
let mut app = on_four_workflows();
select(&mut app, 3);
app.run("workflow.cancel", None);
let c = app.confirm.clone().expect("confirmed");
assert_eq!(c.len(), 3);
assert!(c.is_batch());
let ids: Vec<&str> = c.mutations.iter().map(|m| m.workflow_id()).collect();
assert_eq!(ids, ["order-r1", "order-r2", "order-r3"]);
assert_eq!(c.first().verb(), "Cancel");
}
#[test]
fn without_a_selection_a_mutation_still_covers_one_row() {
let mut app = on_four_workflows();
app.run("workflow.cancel", None);
let c = app.confirm.clone().expect("confirmed");
assert_eq!(c.len(), 1);
assert!(!c.is_batch());
assert_eq!(c.first().workflow_id(), "order-r1");
}
#[test]
fn a_selection_upwards_covers_the_same_rows_as_one_downwards() {
let mut app = on_four_workflows();
app.handle(Msg::Key(Chord::ch('j')));
app.handle(Msg::Key(Chord::ch('j')));
app.run("mode.visual-line", None);
app.handle(Msg::Key(Chord::ch('k')));
app.run("workflow.cancel", None);
let c = app.confirm.clone().unwrap();
let ids: Vec<&str> = c.mutations.iter().map(|m| m.workflow_id()).collect();
assert_eq!(ids, ["order-r2", "order-r3"]);
}
#[test]
fn a_destructive_batch_costs_the_count_rather_than_a_keypress() {
let mut app = on_four_workflows();
select(&mut app, 3);
app.run("workflow.terminate", None);
let c = app.confirm.clone().unwrap();
assert_eq!(c.typed_word.as_deref(), Some("3"));
assert!(!c.is_satisfied(), "Enter alone does not go ahead");
for ch in "3".chars() {
app.handle(Msg::Key(Chord::ch(ch)));
}
assert!(app.confirm.clone().unwrap().is_satisfied());
}
#[test]
fn a_batch_that_destroys_histories_still_costs_the_word() {
let mut app = on_four_workflows();
select(&mut app, 2);
app.run("workflow.delete", None);
assert_eq!(
app.confirm.clone().unwrap().typed_word.as_deref(),
Some("delete")
);
}
#[test]
fn a_single_non_destructive_action_still_costs_nothing() {
let mut app = on_four_workflows();
app.run("workflow.cancel", None);
let c = app.confirm.clone().unwrap();
assert_eq!(c.typed_word, None);
assert!(c.is_satisfied());
}
#[test]
fn running_a_batch_spends_the_selection() {
let mut app = on_four_workflows();
select(&mut app, 2);
assert!(app.view.selection().is_some());
app.run("workflow.cancel", None);
app.handle(Msg::Key(Chord::ch('2')));
app.handle(Msg::Key(Chord::plain(tmprl_core::Key::Enter)));
assert!(app.view.selection().is_none());
assert_eq!(app.mode, Mode::Normal);
}
#[test]
fn a_signal_over_a_selection_sends_the_same_name_to_each() {
let mut app = on_four_workflows();
select(&mut app, 2);
app.run("workflow.signal", None);
for ch in "retry".chars() {
app.handle(Msg::Key(Chord::ch(ch)));
}
app.handle(Msg::Key(Chord::plain(tmprl_core::Key::Enter)));
let c = app.confirm.clone().expect("confirmed");
assert_eq!(c.len(), 2);
assert!(c.mutations.iter().all(|m| m.cli().contains("--name retry")));
assert_eq!(c.typed_word, None, "a signal is not a loss");
}
#[test]
fn a_batch_reports_progress_rather_than_each_row_in_turn() {
let mut app = on_four_workflows();
app.handle(Msg::Mutated {
mutation: Box::new(Mutation::Cancel {
namespace: "default".into(),
workflow_id: "order-r1".into(),
run_id: "r1".into(),
}),
result: Ok(()),
batch: Some((2, 3)),
});
let (msg, _) = app.note.clone().unwrap();
assert_eq!(msg, "cancelled 2/3");
}
#[test]
fn a_signal_asks_for_its_name_before_confirming() {
let mut app = on_a_workflow();
app.run("workflow.signal", None);
assert!(app.confirm.is_none(), "a signal needs a name first");
assert_eq!(app.prompt.clone().unwrap().kind, PromptKind::Signal);
for ch in "retry".chars() {
app.handle(Msg::Key(Chord::ch(ch)));
}
app.handle(Msg::Key(Chord::plain(tmprl_core::Key::Enter)));
let c = app.confirm.clone().expect("now it can be confirmed");
assert!(
c.first().cli().contains("--name retry"),
"{}",
c.first().cli()
);
assert!(!c.first().is_destructive(), "a signal is not a loss");
}
#[test]
fn a_reset_resolves_to_a_workflow_task_the_cursor_is_not_on() {
use tmprl_core::history::{Category as C, GroupRef as G, Outcome as O, Role as R};
let mut app = on_a_workflow();
app.run("nav.open", None);
app.handle(Msg::History {
generation: app.view.generation,
result: Ok((
vec![
hev(1, G::Workflow, R::Opens, C::Workflow).with_subject("W"),
hev(2, G::Opened(2), R::Opens, C::WorkflowTask),
hev(3, G::Opened(2), R::Closes, C::WorkflowTask).with_outcome(O::Completed),
hev(4, G::Opened(4), R::Opens, C::Activity).with_subject("A"),
hev(5, G::Opened(4), R::Closes, C::Activity).with_outcome(O::Completed),
],
Vec::new(),
)),
});
app.run("motion.bottom", None);
app.run("workflow.reset", None);
let c = app.confirm.clone().expect("a confirmation");
assert!(
c.first().cli().contains("--event-id 3"),
"should resolve back to the completed workflow task: {}",
c.first().cli()
);
assert!(c.first().is_destructive(), "a reset abandons work");
}
#[test]
fn a_reset_needs_a_history_and_says_so() {
let mut app = on_a_workflow(); app.run("workflow.reset", None);
assert!(app.confirm.is_none());
let (msg, kind) = app.note.clone().unwrap();
assert_eq!(kind, Note::Warn);
assert!(msg.contains("history"), "got {msg}");
}
#[test]
fn a_history_with_no_completed_task_cannot_be_reset() {
use tmprl_core::history::{Category as C, GroupRef as G, Role as R};
let mut app = on_a_workflow();
app.run("nav.open", None);
app.handle(Msg::History {
generation: app.view.generation,
result: Ok((
vec![hev(1, G::Workflow, R::Opens, C::Workflow).with_subject("W")],
Vec::new(),
)),
});
app.run("workflow.reset", None);
assert!(app.confirm.is_none(), "there is nowhere valid to reset to");
}
#[test]
fn an_update_asks_for_its_name_and_is_not_destructive() {
let mut app = on_a_workflow();
app.run("workflow.update", None);
assert_eq!(app.prompt.clone().unwrap().kind, PromptKind::Update);
assert_eq!(app.prompt.clone().unwrap().sigil(), "update:");
for ch in "setLimit".chars() {
app.handle(Msg::Key(Chord::ch(ch)));
}
app.handle(Msg::Key(Chord::plain(tmprl_core::Key::Enter)));
let c = app.confirm.clone().expect("a confirmation");
assert!(
c.first().cli().contains("update execute"),
"{}",
c.first().cli()
);
assert!(c.first().cli().contains("--name setLimit"));
assert!(
!c.first().is_destructive(),
"an update adds, it does not end"
);
}
#[test]
fn a_signal_and_an_update_do_not_get_confused() {
let mut app = on_a_workflow();
app.run("workflow.signal", None);
for ch in "ping".chars() {
app.handle(Msg::Key(Chord::ch(ch)));
}
app.handle(Msg::Key(Chord::plain(tmprl_core::Key::Enter)));
let cli = app.confirm.clone().unwrap().first().cli();
assert!(cli.contains("workflow signal"), "{cli}");
assert!(!cli.contains("update"), "{cli}");
}
#[test]
fn a_finished_mutation_reports_and_refreshes() {
let mut app = on_a_workflow();
let m = Mutation::Cancel {
namespace: "default".into(),
workflow_id: "order-r1".into(),
run_id: "r1".into(),
};
app.handle(Msg::Mutated {
mutation: Box::new(m),
result: Ok(()),
batch: None,
});
let (msg, kind) = app.note.clone().unwrap();
assert_eq!(kind, Note::Info);
assert!(msg.contains("order-r1"), "got {msg}");
}
#[test]
fn a_failed_mutation_shows_the_servers_reason() {
let mut app = on_a_workflow();
app.handle(Msg::Mutated {
mutation: Box::new(Mutation::Cancel {
namespace: "default".into(),
workflow_id: "w".into(),
run_id: "r".into(),
}),
result: Err("PermissionDenied: not allowed".into()),
batch: None,
});
let (msg, kind) = app.note.clone().unwrap();
assert_eq!(kind, Note::Error);
assert!(msg.contains("PermissionDenied"), "got {msg}");
}
fn on_schedules() -> App {
let mut app = app();
app.view.screen = Screen::Schedules;
app.view.scope = vec!["default".into()];
app.view.schedules = Loadable::loaded(vec![ScheduleRow {
namespace: "default".into(),
schedule_id: "nightly".into(),
workflow_type: "Reconcile".into(),
paused: false,
notes: String::new(),
spec: "0 2 * * *".into(),
next_run: None,
recent_runs: 0,
}]);
app
}
#[test]
fn creating_a_schedule_collects_every_field_then_confirms() {
let mut app = on_schedules();
app.run("schedule.create", None);
let form = app.form.clone().expect("a form opens");
assert_eq!(form.cursor, 0);
assert!(app.confirm.is_none(), "nothing is proposed yet");
for (i, v) in ["nightly", "recon", "OrderWorkflow", "demo-tq", "0 2 * * *"]
.iter()
.enumerate()
{
for ch in v.chars() {
app.handle(Msg::Key(Chord::ch(ch)));
}
if i < 4 {
app.handle(Msg::Key(Chord::plain(tmprl_core::Key::Tab)));
}
}
app.handle(Msg::Key(Chord::plain(tmprl_core::Key::Enter)));
assert!(app.form.is_none(), "the form closes once it is complete");
let cli = app.confirm.clone().expect("confirmed").first().cli();
assert!(cli.contains("--schedule-id nightly"), "{cli}");
assert!(cli.contains("--workflow-id recon"), "{cli}");
assert!(cli.contains("--type OrderWorkflow"), "{cli}");
assert!(cli.contains("--task-queue demo-tq"), "{cli}");
assert!(cli.contains("--cron '0 2 * * *'"), "{cli}");
assert!(!cli.contains("--input"), "input was left empty: {cli}");
}
#[test]
fn an_incomplete_schedule_sends_the_caret_to_the_field_that_is_missing() {
let mut app = on_schedules();
app.run("schedule.create", None);
for ch in "nightly".chars() {
app.handle(Msg::Key(Chord::ch(ch)));
}
app.handle(Msg::Key(Chord::plain(tmprl_core::Key::Enter)));
assert!(app.confirm.is_none(), "nothing is proposed");
let form = app.form.clone().expect("the form stays open");
assert_eq!(form.fields[form.cursor].label, "workflow id");
let (msg, kind) = app.note.clone().unwrap();
assert_eq!(kind, Note::Warn);
assert!(msg.contains("workflow id"), "got {msg}");
}
#[test]
fn backspace_on_an_empty_field_steps_back_rather_than_closing_the_form() {
let mut app = on_schedules();
app.run("schedule.create", None);
app.handle(Msg::Key(Chord::plain(tmprl_core::Key::Tab)));
app.handle(Msg::Key(Chord::plain(tmprl_core::Key::Backspace)));
let form = app.form.clone().expect("still open");
assert_eq!(form.fields[form.cursor].label, "schedule id");
}
#[test]
fn a_schedule_form_takes_literal_keys_rather_than_running_commands() {
let mut app = on_schedules();
app.run("schedule.create", None);
for ch in "jq".chars() {
app.handle(Msg::Key(Chord::ch(ch)));
}
assert_eq!(app.form.clone().unwrap().get("schedule id"), "jq");
assert!(!app.should_quit);
}
#[test]
fn a_backfill_asks_for_its_window_before_confirming() {
let mut app = on_schedules();
app.run("schedule.backfill", None);
assert!(app.confirm.is_none(), "a backfill needs a window first");
assert_eq!(app.prompt.clone().unwrap().kind, PromptKind::Backfill);
for ch in "-1d..now".chars() {
app.handle(Msg::Key(Chord::ch(ch)));
}
app.handle(Msg::Key(Chord::plain(tmprl_core::Key::Enter)));
let m = app
.confirm
.clone()
.expect("now it can be confirmed")
.first()
.clone();
assert_eq!(m.verb(), "Backfill");
assert_eq!(m.schedule_id(), Some("nightly"));
let cli = m.cli();
assert!(cli.contains("--overlap-policy BufferAll"), "{cli}");
assert!(!m.is_destructive(), "it starts runs, it destroys nothing");
}
#[test]
fn an_unreadable_backfill_window_stops_at_the_prompt() {
let mut app = on_schedules();
app.run("schedule.backfill", None);
for ch in "yesterday".chars() {
app.handle(Msg::Key(Chord::ch(ch)));
}
app.handle(Msg::Key(Chord::plain(tmprl_core::Key::Enter)));
assert!(app.confirm.is_none(), "nothing to confirm");
let (msg, kind) = app.note.clone().unwrap();
assert_eq!(kind, Note::Warn);
assert!(msg.contains("START..END"), "got {msg}");
}
#[test]
fn pausing_toggles_towards_the_opposite_of_now() {
let mut app = on_schedules();
app.run("schedule.pause", None);
let m = app.confirm.clone().unwrap().first().clone();
assert_eq!(m.verb(), "Pause");
assert!(m.cli().ends_with("--pause"));
app.confirm = None;
app.view.schedules.value_mut().unwrap()[0].paused = true;
app.run("schedule.pause", None);
let m = app.confirm.clone().unwrap().first().clone();
assert_eq!(m.verb(), "Resume");
assert!(m.cli().ends_with("--unpause"));
}
#[test]
fn a_paused_schedule_shows_the_new_state_before_the_list_catches_up() {
let mut app = on_schedules();
app.handle(Msg::Mutated {
mutation: Box::new(Mutation::PauseSchedule {
namespace: "default".into(),
schedule_id: "nightly".into(),
paused: true,
}),
result: Ok(()),
batch: None,
});
assert!(app.view.schedule_rows()[0].paused);
}
#[test]
fn deleting_a_schedule_costs_the_typed_word() {
let mut app = on_schedules();
app.run("schedule.delete", None);
let c = app.confirm.clone().unwrap();
assert_eq!(c.typed_word.as_deref(), Some("delete"));
assert!(c.first().cli().starts_with("temporal schedule delete "));
}
#[test]
fn schedule_keys_need_a_schedule_under_the_cursor() {
let mut app = app(); app.run("schedule.trigger", None);
assert!(app.confirm.is_none());
assert!(matches!(app.note, Some((_, Note::Warn))));
}
#[test]
fn gs_and_gw_switch_lists_within_a_namespace() {
let mut app = app();
loaded(&mut app, vec![wf("default", "r1", 100)], vec![]);
assert_eq!(app.view.screen, Screen::Workflows);
app.run("nav.schedules", None);
assert_eq!(app.view.screen, Screen::Schedules);
app.run("nav.workflows", None);
assert_eq!(app.view.screen, Screen::Workflows);
}
#[test]
fn switching_lists_is_refused_from_a_namespace_or_a_history() {
let mut app = app();
app.run("nav.schedules", None);
assert_eq!(app.view.screen, Screen::Namespaces);
assert!(matches!(app.note, Some((_, Note::Warn))));
loaded(&mut app, vec![wf("default", "r1", 100)], vec![]);
app.run("nav.open", None);
assert_eq!(app.view.screen, Screen::History);
app.run("nav.schedules", None);
assert_eq!(app.view.screen, Screen::History, "still in the history");
}
#[test]
fn dash_from_schedules_goes_back_to_namespaces() {
let mut app = on_schedules();
app.run("nav.up", None);
assert_eq!(app.view.screen, Screen::Namespaces);
}
#[test]
fn config_errors_are_surfaced_rather_than_swallowed() {
let mut app = app();
app.apply_config(Some("[normal]\n\"x\" = \"nope.nope\"\n"), None, None);
let (msg, kind) = app.note.clone().expect("a bad binding must be reported");
assert_eq!(kind, Note::Error);
assert!(msg.contains("nope.nope"), "got {msg}");
}
#[test]
fn views_from_config_become_commands_and_bindings() {
let mut app = app();
app.apply_config(
None,
Some("[[view]]\nkey = \"1\"\nname = \"Running\"\nquery = \"ExecutionStatus = 'Running'\"\n"),
None,
);
assert!(app.note.is_none(), "a valid config must not warn");
assert_eq!(app.registry.get("view.1").unwrap().title, "Running");
app.handle(Msg::Key(Chord::ch(' ')));
app.handle(Msg::Key(Chord::ch('1')));
assert_eq!(app.view.query, "ExecutionStatus = 'Running'");
}
#[test]
fn opening_nothing_says_so_instead_of_changing_screen() {
let mut app = app();
app.run("nav.open", None);
assert_eq!(app.view.screen, Screen::Namespaces);
assert!(matches!(app.note, Some((_, Note::Warn))));
}
#[test]
fn going_up_from_the_top_level_says_so() {
let mut app = app();
app.run("nav.up", None);
assert_eq!(app.view.screen, Screen::Namespaces);
assert!(matches!(app.note, Some((_, Note::Warn))));
}
}