fix: align backend worker picker style

This commit is contained in:
Keisuke Hirata 2026-07-21 18:12:33 +09:00
parent 2e2d7b7290
commit 4a08b69b4c
No known key found for this signature in database

View File

@ -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"
);
} }
} }