fix: align backend worker picker style
This commit is contained in:
parent
2e2d7b7290
commit
4a08b69b4c
|
|
@ -1,19 +1,23 @@
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::io;
|
use std::io;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use client::{
|
use client::{
|
||||||
BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary, list_backend_workers,
|
BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary, list_backend_workers,
|
||||||
};
|
};
|
||||||
use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind};
|
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
use ratatui::Frame;
|
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Constraint, Layout};
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
|
use ratatui::widgets::Paragraph;
|
||||||
|
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
|
||||||
|
|
||||||
use crate::console;
|
use crate::console;
|
||||||
|
|
||||||
|
const MAX_ROWS: usize = 10;
|
||||||
|
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
|
||||||
|
|
||||||
pub(crate) async fn run(target: BackendRuntimeListTarget) -> Result<(), Box<dyn Error>> {
|
pub(crate) async fn run(target: BackendRuntimeListTarget) -> Result<(), Box<dyn Error>> {
|
||||||
let response = list_backend_workers(&target).await.map_err(|error| {
|
let response = list_backend_workers(&target).await.map_err(|error| {
|
||||||
io::Error::other(format!(
|
io::Error::other(format!(
|
||||||
|
|
@ -55,29 +59,26 @@ fn pick_worker(
|
||||||
.then_with(|| a.label.cmp(&b.label))
|
.then_with(|| a.label.cmp(&b.label))
|
||||||
.then_with(|| a.worker_id.cmp(&b.worker_id))
|
.then_with(|| a.worker_id.cmp(&b.worker_id))
|
||||||
});
|
});
|
||||||
let mut state = BackendWorkerPickerState::new(target, workers);
|
workers.truncate(MAX_ROWS);
|
||||||
let mut terminal = ratatui::Terminal::new(CrosstermBackend::new(io::stdout()))?;
|
|
||||||
terminal.clear()?;
|
|
||||||
|
|
||||||
|
let mut state = BackendWorkerPickerState::new(target, workers);
|
||||||
|
let mut terminal = make_inline_terminal()?;
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|frame| draw(frame, &mut state))?;
|
terminal.draw(|frame| draw(frame, &state))?;
|
||||||
match event::read()? {
|
match poll_event()? {
|
||||||
CrosstermEvent::Key(KeyEvent {
|
None => continue,
|
||||||
code,
|
Some(Action::Up) => state.previous(),
|
||||||
kind: KeyEventKind::Press,
|
Some(Action::Down) => state.next(),
|
||||||
..
|
Some(Action::Submit) => {
|
||||||
}) => match code {
|
close_viewport(&mut terminal)?;
|
||||||
KeyCode::Up | KeyCode::Char('k') => state.previous(),
|
return Ok(state.selected_worker().clone());
|
||||||
KeyCode::Down | KeyCode::Char('j') => state.next(),
|
}
|
||||||
KeyCode::Enter => return Ok(state.selected_worker().clone()),
|
Some(Action::Cancel) => {
|
||||||
KeyCode::Esc | KeyCode::Char('q') => {
|
close_viewport(&mut terminal)?;
|
||||||
return Err(Box::new(io::Error::other(
|
return Err(Box::new(io::Error::other(
|
||||||
"Backend worker picker cancelled",
|
"Backend worker picker cancelled",
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -98,10 +99,9 @@ impl BackendWorkerPickerState {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next(&mut self) {
|
fn next(&mut self) {
|
||||||
if self.workers.is_empty() {
|
if self.selected + 1 < self.workers.len() {
|
||||||
return;
|
self.selected += 1;
|
||||||
}
|
}
|
||||||
self.selected = (self.selected + 1).min(self.workers.len() - 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn previous(&mut self) {
|
fn previous(&mut self) {
|
||||||
|
|
@ -113,119 +113,192 @@ impl BackendWorkerPickerState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw(frame: &mut Frame<'_>, state: &mut BackendWorkerPickerState) {
|
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
||||||
let chunks = Layout::default()
|
let backend = CrosstermBackend::new(io::stdout());
|
||||||
.direction(Direction::Vertical)
|
Terminal::with_options(
|
||||||
.constraints([
|
backend,
|
||||||
Constraint::Length(4),
|
TerminalOptions {
|
||||||
Constraint::Min(8),
|
viewport: Viewport::Inline(VIEWPORT_LINES),
|
||||||
Constraint::Length(5),
|
},
|
||||||
])
|
)
|
||||||
.split(frame.area());
|
|
||||||
|
|
||||||
draw_header(frame, chunks[0], state);
|
|
||||||
draw_list(frame, chunks[1], state);
|
|
||||||
draw_details(frame, chunks[2], state.selected_worker());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_header(frame: &mut Frame<'_>, area: Rect, state: &BackendWorkerPickerState) {
|
fn close_viewport(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
|
||||||
let workspace = state
|
let area = terminal.get_frame().area();
|
||||||
.target
|
let last_row = area.bottom().saturating_sub(1);
|
||||||
.workspace_id
|
terminal.set_cursor_position((0, last_row))?;
|
||||||
.as_deref()
|
use std::io::Write;
|
||||||
.unwrap_or("unscoped backend");
|
let mut out = io::stdout();
|
||||||
let runtime = state.target.runtime_id.as_deref().unwrap_or("all runtimes");
|
out.write_all(b"\r\n")?;
|
||||||
let text = vec![
|
out.flush()?;
|
||||||
Line::from(vec![
|
Ok(())
|
||||||
Span::styled(
|
}
|
||||||
"Backend runtime workers",
|
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
enum Action {
|
||||||
),
|
Up,
|
||||||
Span::raw(format!(" {}", state.target.base_url)),
|
Down,
|
||||||
]),
|
Submit,
|
||||||
Line::from(format!("workspace: {workspace} runtime: {runtime}")),
|
Cancel,
|
||||||
Line::from("↑/↓ or k/j select Enter attach q/Esc cancel"),
|
}
|
||||||
];
|
|
||||||
|
fn poll_event() -> io::Result<Option<Action>> {
|
||||||
|
if !event::poll(Duration::from_millis(100))? {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
match event::read()? {
|
||||||
|
TermEvent::Key(k) if k.kind != KeyEventKind::Release => {
|
||||||
|
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
Ok(match k.code {
|
||||||
|
KeyCode::Up => Some(Action::Up),
|
||||||
|
KeyCode::Down => Some(Action::Down),
|
||||||
|
KeyCode::Char('k') if !ctrl => Some(Action::Up),
|
||||||
|
KeyCode::Char('j') if !ctrl => Some(Action::Down),
|
||||||
|
KeyCode::Enter => Some(Action::Submit),
|
||||||
|
KeyCode::Esc => Some(Action::Cancel),
|
||||||
|
KeyCode::Char('c') if ctrl => Some(Action::Cancel),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(frame: &mut Frame<'_>, state: &BackendWorkerPickerState) {
|
||||||
|
let area = frame.area();
|
||||||
|
let mut constraints: Vec<Constraint> = Vec::with_capacity(state.workers.len() + 3);
|
||||||
|
constraints.push(Constraint::Length(1));
|
||||||
|
for _ in &state.workers {
|
||||||
|
constraints.push(Constraint::Length(1));
|
||||||
|
}
|
||||||
|
constraints.push(Constraint::Length(1));
|
||||||
|
constraints.push(Constraint::Length(1));
|
||||||
|
let layout = Layout::vertical(constraints).split(area);
|
||||||
|
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(text).block(Block::default().borders(Borders::ALL)),
|
Paragraph::new(Line::from(vec![Span::styled(
|
||||||
area,
|
picker_title(&state.target),
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
)])),
|
||||||
|
layout[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
for (i, worker) in state.workers.iter().enumerate() {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(row_line(worker, i == state.selected)),
|
||||||
|
layout[i + 1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("[↑/↓]", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(" select "),
|
||||||
|
Span::styled("[enter]", Style::default().fg(Color::Green)),
|
||||||
|
Span::raw(" attach "),
|
||||||
|
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::raw(" cancel"),
|
||||||
|
])),
|
||||||
|
layout[state.workers.len() + 1],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_list(frame: &mut Frame<'_>, area: Rect, state: &mut BackendWorkerPickerState) {
|
fn picker_title(target: &BackendRuntimeListTarget) -> String {
|
||||||
let items: Vec<_> = state
|
let workspace = target
|
||||||
.workers
|
.workspace_id
|
||||||
.iter()
|
.as_deref()
|
||||||
.map(|worker| ListItem::new(worker_row(worker)))
|
.map(short_text)
|
||||||
.collect();
|
.unwrap_or_else(|| "unscoped".to_string());
|
||||||
let mut list_state = ListState::default().with_selected(Some(state.selected));
|
let runtime = target
|
||||||
let list = List::new(items)
|
.runtime_id
|
||||||
.block(Block::default().borders(Borders::ALL).title("Workers"))
|
.as_deref()
|
||||||
.highlight_symbol("▶ ")
|
.map(short_text)
|
||||||
.highlight_style(
|
.unwrap_or_else(|| "all runtimes".to_string());
|
||||||
Style::default()
|
format!("backend workers workspace: {workspace} runtime: {runtime}")
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
);
|
|
||||||
frame.render_stateful_widget(list, area, &mut list_state);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn worker_row(worker: &BackendWorkerSummary) -> Line<'static> {
|
fn row_line(worker: &BackendWorkerSummary, selected: bool) -> Line<'static> {
|
||||||
|
let marker = if selected { "▶ " } else { " " };
|
||||||
|
let id_style = if selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Cyan)
|
||||||
|
};
|
||||||
|
let preview_style = if selected {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
|
||||||
let label = if worker.label.is_empty() {
|
let label = if worker.label.is_empty() {
|
||||||
worker.worker_id.as_str()
|
worker.worker_id.as_str()
|
||||||
} else {
|
} else {
|
||||||
worker.label.as_str()
|
worker.label.as_str()
|
||||||
};
|
};
|
||||||
let profile = worker.profile.as_deref().unwrap_or("-");
|
let profile = worker.profile.as_deref().unwrap_or("-");
|
||||||
let wd = working_directory_text(worker);
|
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
|
Span::raw(marker),
|
||||||
|
Span::styled(short_worker_id(worker), id_style),
|
||||||
|
Span::raw(" "),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{}:{}", worker.runtime_id, worker.worker_id),
|
format!("[{}]", worker.state),
|
||||||
Style::default().fg(Color::Cyan),
|
state_style(worker.state.as_str()),
|
||||||
),
|
),
|
||||||
Span::raw(format!(" {label}")),
|
Span::raw(" "),
|
||||||
Span::raw(format!(" profile:{profile}")),
|
Span::styled(
|
||||||
Span::raw(format!(" state:{}", worker.state)),
|
format!("profile:{profile}"),
|
||||||
Span::raw(format!(" wd:{wd}")),
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
working_directory_text(worker),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(label.to_string(), preview_style),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_details(frame: &mut Frame<'_>, area: Rect, worker: &BackendWorkerSummary) {
|
fn state_style(state: &str) -> Style {
|
||||||
let profile = worker.profile.as_deref().unwrap_or("-");
|
match state {
|
||||||
let role = worker.role.as_deref().unwrap_or("-");
|
"running" | "idle" | "active" => Style::default()
|
||||||
let text = vec![
|
.fg(Color::Green)
|
||||||
Line::from(format!(
|
.add_modifier(Modifier::BOLD),
|
||||||
"runtime={} worker={} host={}",
|
"stopped" | "complete" => Style::default().fg(Color::Yellow),
|
||||||
worker.runtime_id, worker.worker_id, worker.host_id
|
"failed" | "error" => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||||
)),
|
_ => Style::default().fg(Color::Yellow),
|
||||||
Line::from(format!(
|
}
|
||||||
"label={} role={} profile={} state={}",
|
}
|
||||||
worker.label, role, profile, worker.state
|
|
||||||
)),
|
fn short_worker_id(worker: &BackendWorkerSummary) -> String {
|
||||||
Line::from(format!(
|
format!(
|
||||||
"working_directory={}",
|
"{}:{}",
|
||||||
working_directory_text(worker)
|
short_text(&worker.runtime_id),
|
||||||
)),
|
short_text(&worker.worker_id)
|
||||||
];
|
)
|
||||||
frame.render_widget(
|
}
|
||||||
Paragraph::new(text)
|
|
||||||
.block(
|
fn short_text(text: &str) -> String {
|
||||||
Block::default()
|
const MAX: usize = 24;
|
||||||
.borders(Borders::ALL)
|
let mut chars = text.chars();
|
||||||
.title("Selected worker"),
|
let shortened: String = chars.by_ref().take(MAX).collect();
|
||||||
)
|
if chars.next().is_some() {
|
||||||
.wrap(Wrap { trim: true }),
|
format!("{shortened}…")
|
||||||
area,
|
} else {
|
||||||
);
|
shortened
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn working_directory_text(worker: &BackendWorkerSummary) -> String {
|
fn working_directory_text(worker: &BackendWorkerSummary) -> String {
|
||||||
let Some(wd) = worker.working_directory.as_ref() else {
|
let Some(wd) = worker.working_directory.as_ref() else {
|
||||||
return "-".to_string();
|
return "wd:—".to_string();
|
||||||
};
|
};
|
||||||
let cleanliness = wd.cleanliness.as_deref().unwrap_or("unknown");
|
let cleanliness = wd.cleanliness.as_deref().unwrap_or("unknown");
|
||||||
format!(
|
format!(
|
||||||
"{}:{} {} {}",
|
"wd:{}:{} {} {}",
|
||||||
wd.repository_id, wd.working_directory_id, wd.status, cleanliness
|
wd.repository_id, wd.working_directory_id, wd.status, cleanliness
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -268,15 +341,29 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_row_contains_backend_authority_fields() {
|
fn worker_row_matches_inline_picker_shape() {
|
||||||
let row = worker_row(&worker("runtime-a", "worker-b", Some("default")));
|
let row = row_line(&worker("runtime-a", "worker-b", Some("default")), true);
|
||||||
let text = row
|
let text = row
|
||||||
.spans
|
.spans
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|span| span.content)
|
.map(|span| span.content)
|
||||||
.collect::<String>();
|
.collect::<String>();
|
||||||
assert!(text.contains("runtime-a:worker-b"));
|
assert!(text.starts_with("▶ runtime-a:worker-b"));
|
||||||
|
assert!(text.contains("[running]"));
|
||||||
assert!(text.contains("profile:default"));
|
assert!(text.contains("profile:default"));
|
||||||
assert!(text.contains("state:running"));
|
assert!(text.contains("wd:—"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picker_title_uses_backend_worker_wording() {
|
||||||
|
let target = BackendRuntimeListTarget::new(
|
||||||
|
"http://127.0.0.1:8787",
|
||||||
|
Some("workspace-abcdef".to_string()),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
picker_title(&target),
|
||||||
|
"backend workers workspace: workspace-abcdef runtime: all runtimes"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user