chore: merge current develop into T-588
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
use client::{
|
||||
BackendCreateWorkerRequest, BackendWorkerLaunchOptions, BackendWorkerLaunchProfileCandidate,
|
||||
BackendWorkerLaunchRuntimeOption, BackendWorkerLaunchTarget, create_backend_worker,
|
||||
get_backend_worker_launch_options,
|
||||
};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
|
||||
use crate::backend_workspace_picker::select_backend_workspace;
|
||||
use crate::console;
|
||||
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Field {
|
||||
Name,
|
||||
Runtime,
|
||||
Profile,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Name => Self::Runtime,
|
||||
Self::Runtime => Self::Profile,
|
||||
Self::Profile => Self::Name,
|
||||
}
|
||||
}
|
||||
|
||||
fn previous(self) -> Self {
|
||||
match self {
|
||||
Self::Name => Self::Profile,
|
||||
Self::Runtime => Self::Name,
|
||||
Self::Profile => Self::Runtime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Selection {
|
||||
runtime_id: String,
|
||||
display_name: String,
|
||||
profile: String,
|
||||
}
|
||||
|
||||
struct FormState {
|
||||
field: Field,
|
||||
display_name: String,
|
||||
runtime_index: usize,
|
||||
profile_index: usize,
|
||||
status: String,
|
||||
}
|
||||
|
||||
impl FormState {
|
||||
fn new(options: &BackendWorkerLaunchOptions) -> Self {
|
||||
let runtime_index = options
|
||||
.runtimes
|
||||
.iter()
|
||||
.position(runtime_supports_workdirless_creation)
|
||||
.unwrap_or(0);
|
||||
let profile_index = options
|
||||
.default_profile
|
||||
.as_deref()
|
||||
.and_then(|default| {
|
||||
options
|
||||
.profiles
|
||||
.iter()
|
||||
.position(|candidate| candidate.id == default)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
Self {
|
||||
field: Field::Name,
|
||||
display_name: "Worker".to_string(),
|
||||
runtime_index,
|
||||
profile_index,
|
||||
status: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_runtime<'a>(
|
||||
&self,
|
||||
options: &'a BackendWorkerLaunchOptions,
|
||||
) -> Option<&'a BackendWorkerLaunchRuntimeOption> {
|
||||
options.runtimes.get(self.runtime_index)
|
||||
}
|
||||
|
||||
fn current_profile<'a>(
|
||||
&self,
|
||||
options: &'a BackendWorkerLaunchOptions,
|
||||
) -> Option<&'a BackendWorkerLaunchProfileCandidate> {
|
||||
options.profiles.get(self.profile_index)
|
||||
}
|
||||
|
||||
fn cycle_runtime(&mut self, options: &BackendWorkerLaunchOptions, delta: isize) {
|
||||
self.runtime_index = cycle_index(self.runtime_index, options.runtimes.len(), delta);
|
||||
self.status.clear();
|
||||
}
|
||||
|
||||
fn cycle_profile(&mut self, options: &BackendWorkerLaunchOptions, delta: isize) {
|
||||
self.profile_index = cycle_index(self.profile_index, options.profiles.len(), delta);
|
||||
self.status.clear();
|
||||
}
|
||||
|
||||
fn submit(&mut self, options: &BackendWorkerLaunchOptions) -> Option<Selection> {
|
||||
let display_name = self.display_name.trim();
|
||||
if display_name.is_empty() {
|
||||
self.status = "Worker name is required.".to_string();
|
||||
self.field = Field::Name;
|
||||
return None;
|
||||
}
|
||||
let Some(runtime) = self.current_runtime(options) else {
|
||||
self.status = "No Runtime is available in this Workspace.".to_string();
|
||||
self.field = Field::Runtime;
|
||||
return None;
|
||||
};
|
||||
if !runtime.worker_creation_available {
|
||||
self.status = "The selected Runtime cannot create Workers right now.".to_string();
|
||||
self.field = Field::Runtime;
|
||||
return None;
|
||||
}
|
||||
if runtime.working_directory_required {
|
||||
self.status =
|
||||
"The selected Runtime requires a workdir; this launch flow does not select one yet."
|
||||
.to_string();
|
||||
self.field = Field::Runtime;
|
||||
return None;
|
||||
}
|
||||
let Some(profile) = self.current_profile(options) else {
|
||||
self.status = "No Worker profile is available.".to_string();
|
||||
self.field = Field::Profile;
|
||||
return None;
|
||||
};
|
||||
Some(Selection {
|
||||
runtime_id: runtime.runtime_id.clone(),
|
||||
display_name: display_name.to_string(),
|
||||
profile: profile.id.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(mut target: BackendWorkerLaunchTarget) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if target.workspace_id().is_none() {
|
||||
let Some(workspace) = select_backend_workspace(&target.base_url).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
target.select_workspace(workspace);
|
||||
}
|
||||
|
||||
let options = get_backend_worker_launch_options(&target).await?;
|
||||
let Some(selection) = select_worker(&options)? else {
|
||||
return Ok(());
|
||||
};
|
||||
let request = request_from_selection(selection);
|
||||
let created = create_backend_worker(&target, &request).await?;
|
||||
let runtime_target = target.runtime_target(created.runtime_id, created.worker_id)?;
|
||||
console::run_backend_runtime(runtime_target).await
|
||||
}
|
||||
|
||||
fn request_from_selection(selection: Selection) -> BackendCreateWorkerRequest {
|
||||
BackendCreateWorkerRequest {
|
||||
runtime_id: selection.runtime_id,
|
||||
display_name: selection.display_name,
|
||||
profile: Some(selection.profile),
|
||||
initial_submit: Vec::new(),
|
||||
working_directory: None,
|
||||
ticket_assignment: None,
|
||||
control_operation_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
const VIEWPORT_LINES: u16 = 14;
|
||||
|
||||
fn select_worker(
|
||||
options: &BackendWorkerLaunchOptions,
|
||||
) -> Result<Option<Selection>, Box<dyn std::error::Error>> {
|
||||
with_inline_terminal(VIEWPORT_LINES, |terminal| run_form(terminal, options))
|
||||
}
|
||||
|
||||
fn run_form(
|
||||
terminal: &mut InlineTerminal,
|
||||
options: &BackendWorkerLaunchOptions,
|
||||
) -> Result<Option<Selection>, Box<dyn std::error::Error>> {
|
||||
let mut state = FormState::new(options);
|
||||
|
||||
loop {
|
||||
terminal.draw(|frame| render(frame, &state, options))?;
|
||||
let event = event::read()?;
|
||||
let Event::Key(key) = event else {
|
||||
continue;
|
||||
};
|
||||
if key.kind != KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
return Ok(None);
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Down => {
|
||||
state.field = state.field.next();
|
||||
state.status.clear();
|
||||
}
|
||||
KeyCode::BackTab | KeyCode::Up => {
|
||||
state.field = state.field.previous();
|
||||
state.status.clear();
|
||||
}
|
||||
KeyCode::Left => match state.field {
|
||||
Field::Runtime => state.cycle_runtime(options, -1),
|
||||
Field::Profile => state.cycle_profile(options, -1),
|
||||
Field::Name => {}
|
||||
},
|
||||
KeyCode::Right => match state.field {
|
||||
Field::Runtime => state.cycle_runtime(options, 1),
|
||||
Field::Profile => state.cycle_profile(options, 1),
|
||||
Field::Name => {}
|
||||
},
|
||||
KeyCode::Enter => {
|
||||
if let Some(selection) = state.submit(options) {
|
||||
return Ok(Some(selection));
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace if state.field == Field::Name => {
|
||||
state.display_name.pop();
|
||||
state.status.clear();
|
||||
}
|
||||
KeyCode::Char(character)
|
||||
if state.field == Field::Name
|
||||
&& !key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !character.is_control() =>
|
||||
{
|
||||
state.display_name.push(character);
|
||||
state.status.clear();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render(frame: &mut ratatui::Frame<'_>, state: &FormState, options: &BackendWorkerLaunchOptions) {
|
||||
let area = frame.area();
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
"New Backend Worker",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" Workspace: {}", options.workspace_id)),
|
||||
])),
|
||||
vertical[0],
|
||||
);
|
||||
|
||||
let focused = Style::default().fg(Color::Cyan);
|
||||
frame.render_widget(
|
||||
Paragraph::new(state.display_name.as_str()).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Name ")
|
||||
.border_style(if state.field == Field::Name {
|
||||
focused
|
||||
} else {
|
||||
Style::default()
|
||||
}),
|
||||
),
|
||||
vertical[1],
|
||||
);
|
||||
|
||||
let runtime_text = state
|
||||
.current_runtime(options)
|
||||
.map(runtime_label)
|
||||
.unwrap_or_else(|| "No Runtime available".to_string());
|
||||
frame.render_widget(
|
||||
Paragraph::new(runtime_text).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(runtime_title(state, options))
|
||||
.border_style(if state.field == Field::Runtime {
|
||||
focused
|
||||
} else {
|
||||
Style::default()
|
||||
}),
|
||||
),
|
||||
vertical[2],
|
||||
);
|
||||
|
||||
let profile_text = state
|
||||
.current_profile(options)
|
||||
.map(|profile| {
|
||||
if profile.description.is_empty() {
|
||||
profile.label.clone()
|
||||
} else {
|
||||
format!("{} — {}", profile.label, profile.description)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "No profile available".to_string());
|
||||
frame.render_widget(
|
||||
Paragraph::new(profile_text).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(profile_title(state, options))
|
||||
.border_style(if state.field == Field::Profile {
|
||||
focused
|
||||
} else {
|
||||
Style::default()
|
||||
}),
|
||||
),
|
||||
vertical[3],
|
||||
);
|
||||
|
||||
let status = if state.status.is_empty() {
|
||||
"Tab/↑/↓: field ←/→: choice Enter: create Esc/Ctrl-C: cancel"
|
||||
} else {
|
||||
state.status.as_str()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(status)
|
||||
.style(if state.status.is_empty() {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default().fg(Color::Yellow)
|
||||
})
|
||||
.wrap(Wrap { trim: true }),
|
||||
vertical[4],
|
||||
);
|
||||
|
||||
if state.field == Field::Name {
|
||||
let max_cursor = vertical[1].width.saturating_sub(2) as usize;
|
||||
frame.set_cursor_position((
|
||||
vertical[1].x + 1 + state.display_name.chars().count().min(max_cursor) as u16,
|
||||
vertical[1].y + 1,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_title(state: &FormState, options: &BackendWorkerLaunchOptions) -> String {
|
||||
if options.runtimes.is_empty() {
|
||||
" Runtime ".to_string()
|
||||
} else {
|
||||
format!(
|
||||
" Runtime ({}/{}) ",
|
||||
state.runtime_index + 1,
|
||||
options.runtimes.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_title(state: &FormState, options: &BackendWorkerLaunchOptions) -> String {
|
||||
if options.profiles.is_empty() {
|
||||
" Profile ".to_string()
|
||||
} else {
|
||||
format!(
|
||||
" Profile ({}/{}) ",
|
||||
state.profile_index + 1,
|
||||
options.profiles.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_label(runtime: &BackendWorkerLaunchRuntimeOption) -> String {
|
||||
let availability = if !runtime.worker_creation_available {
|
||||
"unavailable"
|
||||
} else if runtime.working_directory_required {
|
||||
"workdir required"
|
||||
} else {
|
||||
"no workdir"
|
||||
};
|
||||
format!(
|
||||
"{} [{}] — {availability}",
|
||||
runtime.display_name, runtime.runtime_id
|
||||
)
|
||||
}
|
||||
|
||||
fn runtime_supports_workdirless_creation(runtime: &BackendWorkerLaunchRuntimeOption) -> bool {
|
||||
runtime.worker_creation_available && !runtime.working_directory_required
|
||||
}
|
||||
|
||||
fn cycle_index(current: usize, len: usize, delta: isize) -> usize {
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
(current as isize + delta).rem_euclid(len as isize) as usize
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use client::{BackendDiagnostic, BackendWorkerLaunchOptions};
|
||||
|
||||
fn options() -> BackendWorkerLaunchOptions {
|
||||
BackendWorkerLaunchOptions {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
runtimes: vec![
|
||||
BackendWorkerLaunchRuntimeOption {
|
||||
runtime_id: "external".to_string(),
|
||||
display_name: "External".to_string(),
|
||||
built_in: false,
|
||||
worker_creation_available: true,
|
||||
working_directory_required: true,
|
||||
status: "online".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
BackendWorkerLaunchRuntimeOption {
|
||||
runtime_id: "embedded".to_string(),
|
||||
display_name: "Embedded".to_string(),
|
||||
built_in: true,
|
||||
worker_creation_available: true,
|
||||
working_directory_required: false,
|
||||
status: "online".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
],
|
||||
profiles: vec![
|
||||
BackendWorkerLaunchProfileCandidate {
|
||||
id: "builtin:default".to_string(),
|
||||
label: "Default".to_string(),
|
||||
description: String::new(),
|
||||
},
|
||||
BackendWorkerLaunchProfileCandidate {
|
||||
id: "builtin:coder".to_string(),
|
||||
label: "Coder".to_string(),
|
||||
description: "Ticket implementation".to_string(),
|
||||
},
|
||||
],
|
||||
default_profile: Some("builtin:coder".to_string()),
|
||||
repositories: Vec::new(),
|
||||
working_directories: Vec::new(),
|
||||
diagnostics: Vec::<BackendDiagnostic>::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_to_workdirless_runtime_and_backend_default_profile() {
|
||||
let options = options();
|
||||
let state = FormState::new(&options);
|
||||
assert_eq!(
|
||||
state.current_runtime(&options).unwrap().runtime_id,
|
||||
"embedded"
|
||||
);
|
||||
assert_eq!(state.current_profile(&options).unwrap().id, "builtin:coder");
|
||||
assert_eq!(state.display_name, "Worker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_required_runtime_cannot_be_submitted() {
|
||||
let options = options();
|
||||
let mut state = FormState::new(&options);
|
||||
state.runtime_index = 0;
|
||||
assert_eq!(state.submit(&options), None);
|
||||
assert!(state.status.contains("requires a workdir"));
|
||||
assert_eq!(state.field, Field::Runtime);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_builds_workdirless_create_request() {
|
||||
let request = request_from_selection(Selection {
|
||||
runtime_id: "embedded".to_string(),
|
||||
display_name: "Coder one".to_string(),
|
||||
profile: "builtin:coder".to_string(),
|
||||
});
|
||||
assert_eq!(request.runtime_id, "embedded");
|
||||
assert_eq!(request.display_name, "Coder one");
|
||||
assert_eq!(request.profile.as_deref(), Some("builtin:coder"));
|
||||
assert!(request.initial_submit.is_empty());
|
||||
assert!(request.working_directory.is_none());
|
||||
assert!(request.ticket_assignment.is_none());
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::backend_workspace_picker::select_backend_workspace;
|
||||
use crate::console;
|
||||
@@ -235,9 +236,10 @@ fn draw(frame: &mut Frame<'_>, state: &BackendWorkerPickerState) {
|
||||
layout[0],
|
||||
);
|
||||
|
||||
let column_widths = WorkerColumnWidths::from_workers(&state.workers);
|
||||
for (i, worker) in state.workers.iter().enumerate() {
|
||||
frame.render_widget(
|
||||
Paragraph::new(row_line(worker, i == state.selected)),
|
||||
Paragraph::new(row_line(worker, &column_widths, i == state.selected)),
|
||||
layout[i + 1],
|
||||
);
|
||||
}
|
||||
@@ -272,7 +274,28 @@ fn picker_title(target: &BackendRuntimeListTarget) -> String {
|
||||
format!("backend workers workspace: {workspace} runtime: {runtime}")
|
||||
}
|
||||
|
||||
fn row_line(worker: &BackendWorkerSummary, selected: bool) -> Line<'static> {
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
struct WorkerColumnWidths {
|
||||
identity: usize,
|
||||
name: usize,
|
||||
state: usize,
|
||||
}
|
||||
|
||||
impl WorkerColumnWidths {
|
||||
fn from_workers(workers: &[BackendWorkerSummary]) -> Self {
|
||||
workers.iter().fold(Self::default(), |widths, worker| Self {
|
||||
identity: widths.identity.max(text_width(&short_worker_id(worker))),
|
||||
name: widths.name.max(text_width(worker_name(worker))),
|
||||
state: widths.state.max(text_width(&worker_state(worker))),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn row_line(
|
||||
worker: &BackendWorkerSummary,
|
||||
widths: &WorkerColumnWidths,
|
||||
selected: bool,
|
||||
) -> Line<'static> {
|
||||
let marker = if selected { "▶ " } else { " " };
|
||||
let id_style = if selected {
|
||||
Style::default()
|
||||
@@ -281,42 +304,58 @@ fn row_line(worker: &BackendWorkerSummary, selected: bool) -> Line<'static> {
|
||||
} else {
|
||||
Style::default().fg(Color::Cyan)
|
||||
};
|
||||
let preview_style = if selected {
|
||||
let name_style = if selected {
|
||||
Style::default().fg(Color::White)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
let label = if worker.label.is_empty() {
|
||||
worker.worker_id.as_str()
|
||||
} else {
|
||||
worker.label.as_str()
|
||||
};
|
||||
let profile = worker.profile.as_deref().unwrap_or("-");
|
||||
|
||||
Line::from(vec![
|
||||
Span::raw(marker),
|
||||
Span::styled(short_worker_id(worker), id_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("[{}]", worker.state),
|
||||
state_style(worker.state.as_str()),
|
||||
pad_column(&short_worker_id(worker), widths.identity),
|
||||
id_style,
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(pad_column(worker_name(worker), widths.name), name_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("profile:{profile}"),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
pad_column(&worker_state(worker), widths.state),
|
||||
state_style(worker.state.as_str()),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
working_directory_text(worker),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(label.to_string(), preview_style),
|
||||
])
|
||||
}
|
||||
|
||||
fn worker_name(worker: &BackendWorkerSummary) -> &str {
|
||||
if !worker.label.is_empty() {
|
||||
worker.label.as_str()
|
||||
} else if !worker.display_name.is_empty() {
|
||||
worker.display_name.as_str()
|
||||
} else {
|
||||
worker.worker_id.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_state(worker: &BackendWorkerSummary) -> String {
|
||||
format!("[{}]", worker.state)
|
||||
}
|
||||
|
||||
fn text_width(value: &str) -> usize {
|
||||
UnicodeWidthStr::width(value)
|
||||
}
|
||||
|
||||
fn pad_column(value: &str, width: usize) -> String {
|
||||
format!(
|
||||
"{value}{}",
|
||||
" ".repeat(width.saturating_sub(text_width(value)))
|
||||
)
|
||||
}
|
||||
|
||||
fn state_style(state: &str) -> Style {
|
||||
match state {
|
||||
"running" | "idle" | "active" => Style::default()
|
||||
@@ -347,11 +386,7 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String {
|
||||
let Some(wd) = worker.working_directory.as_ref() else {
|
||||
return "wd:—".to_string();
|
||||
};
|
||||
let cleanliness = wd.cleanliness.as_deref().unwrap_or("unknown");
|
||||
format!(
|
||||
"wd:{}:{} {} {}",
|
||||
wd.repository_key, wd.working_directory_id, wd.status, cleanliness
|
||||
)
|
||||
format!("wd:{}・{}", wd.repository_key, wd.working_directory_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -395,18 +430,90 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_row_matches_inline_picker_shape() {
|
||||
let row = row_line(&worker("runtime-a", "worker-b", Some("default")), true);
|
||||
let text = row
|
||||
fn row_text(worker: &BackendWorkerSummary, widths: &WorkerColumnWidths) -> String {
|
||||
row_line(worker, widths, false)
|
||||
.spans
|
||||
.into_iter()
|
||||
.map(|span| span.content)
|
||||
.collect::<String>();
|
||||
assert!(text.starts_with("▶ W-1"));
|
||||
assert!(text.contains("[running]"));
|
||||
assert!(text.contains("profile:default"));
|
||||
assert!(text.contains("wd:—"));
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn display_column(text: &str, value: &str) -> usize {
|
||||
let byte_offset = text.find(value).expect("value in rendered row");
|
||||
text_width(&text[..byte_offset])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_row_orders_and_simplifies_columns() {
|
||||
let mut worker = worker("runtime-a", "worker-b", Some("builtin:coder"));
|
||||
worker.resource_key = "W-90".to_string();
|
||||
worker.display_name = "Coder".to_string();
|
||||
worker.label = "Coder · T-585".to_string();
|
||||
worker.state = "stopped".to_string();
|
||||
worker.working_directory = Some(
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"working_directory_id": "001a06a9f0202000000",
|
||||
"repository_key": "main",
|
||||
"materializer_kind": "local_git_worktree",
|
||||
"status": "active",
|
||||
"cleanliness": "clean"
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
let widths = WorkerColumnWidths::from_workers(std::slice::from_ref(&worker));
|
||||
let text = row_text(&worker, &widths);
|
||||
|
||||
assert_eq!(
|
||||
text,
|
||||
" W-90 Coder · T-585 [stopped] wd:main・001a06a9f0202000000"
|
||||
);
|
||||
assert!(!text.contains("profile:"));
|
||||
assert!(!text.contains("active clean"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_rows_align_identity_name_state_and_workdir_columns() {
|
||||
let mut short = worker("runtime-a", "worker-a", None);
|
||||
short.resource_key = "W-2".to_string();
|
||||
short.label = "Coder".to_string();
|
||||
short.display_name = short.label.clone();
|
||||
short.state = "idle".to_string();
|
||||
|
||||
let mut long = worker("runtime-a", "worker-b", None);
|
||||
long.resource_key = "W-100".to_string();
|
||||
long.label = "Longer worker · T-9".to_string();
|
||||
long.display_name = long.label.clone();
|
||||
long.state = "stopped".to_string();
|
||||
|
||||
for worker in [&mut short, &mut long] {
|
||||
worker.working_directory = Some(
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"working_directory_id": "workdir-1",
|
||||
"repository_key": "main",
|
||||
"materializer_kind": "local_git_worktree",
|
||||
"status": "active"
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
let workers = vec![short, long];
|
||||
let widths = WorkerColumnWidths::from_workers(&workers);
|
||||
let first = row_text(&workers[0], &widths);
|
||||
let second = row_text(&workers[1], &widths);
|
||||
|
||||
assert_eq!(
|
||||
display_column(&first, "Coder"),
|
||||
display_column(&second, "Longer")
|
||||
);
|
||||
assert_eq!(
|
||||
display_column(&first, "[idle]"),
|
||||
display_column(&second, "[stopped]")
|
||||
);
|
||||
assert_eq!(
|
||||
display_column(&first, "wd:main"),
|
||||
display_column(&second, "wd:main")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod app;
|
||||
mod backend_dashboard;
|
||||
mod backend_spawn;
|
||||
mod backend_worker_picker;
|
||||
mod backend_workspace_picker;
|
||||
mod block;
|
||||
@@ -51,6 +52,8 @@ pub enum LaunchMode {
|
||||
/// Restore one client-owned standalone Worker. The current cwd is the default scope;
|
||||
/// `include_all` opts into all standalone Workers under the same client data root.
|
||||
StandaloneResume { include_all: bool },
|
||||
/// Create one Backend Worker and attach to it.
|
||||
BackendSpawn,
|
||||
/// List Backend Workers and attach to the selected Worker.
|
||||
Workers {
|
||||
runtime_id: Option<String>,
|
||||
@@ -161,6 +164,10 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
|
||||
}
|
||||
}
|
||||
LaunchMode::BackendSpawn => match target.launch_backend_worker() {
|
||||
Ok(launch) => backend_spawn::run(launch.target).await,
|
||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||
},
|
||||
LaunchMode::Workers {
|
||||
runtime_id,
|
||||
include_stopped,
|
||||
|
||||
Reference in New Issue
Block a user