feat: add scoped TUI workspace selection

This commit is contained in:
2026-08-20 18:34:18 +09:00
parent 4b8dc302ee
commit 17abe1c40c
8 changed files with 682 additions and 111 deletions
+96 -64
View File
@@ -3,8 +3,8 @@ use std::io;
use std::time::Duration;
use client::{
BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary,
list_backend_stopped_workers, list_backend_workers, restore_backend_worker,
BackendRuntimeListTarget, BackendWorkerSummary, list_backend_stopped_workers,
list_backend_workers, restore_backend_worker,
};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::backend::CrosstermBackend;
@@ -14,77 +14,94 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
use crate::backend_workspace_picker::select_backend_workspace;
use crate::console;
const MAX_ROWS: usize = 10;
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
pub(crate) async fn run(
target: BackendRuntimeListTarget,
mut target: BackendRuntimeListTarget,
include_stopped: bool,
) -> Result<(), Box<dyn Error>> {
let mut response = list_backend_workers(&target).await.map_err(|error| {
io::Error::other(format!(
"failed to list Backend runtime workers from {}: {error}",
target.base_url
))
})?;
if include_stopped {
match list_backend_stopped_workers(&target).await {
Ok(stopped) => {
response.items.extend(stopped.items);
response.diagnostics.extend(stopped.diagnostics);
loop {
if target.workspace_id().is_none() {
let workspace_id = select_backend_workspace(&target.base_url)
.await
.map_err(|error| io::Error::other(error.to_string()))?
.ok_or_else(|| io::Error::other("Backend workspace picker cancelled"))?;
target.select_workspace(workspace_id);
}
let mut response = list_backend_workers(&target).await.map_err(|error| {
io::Error::other(format!(
"failed to list Backend runtime workers from {}: {error}",
target.base_url
))
})?;
if include_stopped {
match list_backend_stopped_workers(&target).await {
Ok(stopped) => {
response.items.extend(stopped.items);
response.diagnostics.extend(stopped.diagnostics);
}
Err(error) => response.diagnostics.push(client::BackendDiagnostic {
code: "backend_stopped_workers_list_failed".to_string(),
severity: Some("error".to_string()),
message: error.to_string(),
}),
}
Err(error) => response.diagnostics.push(client::BackendDiagnostic {
code: "backend_stopped_workers_list_failed".to_string(),
severity: Some("error".to_string()),
message: error.to_string(),
}),
}
dedup_workers(&mut response.items);
if response.items.is_empty() {
let diagnostics = response
.diagnostics
.iter()
.map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message))
.collect::<Vec<_>>()
.join("; ");
let detail = if diagnostics.is_empty() {
"no backend diagnostics".to_string()
} else {
diagnostics
};
eprintln!(
"Backend returned no runtime workers for workspace {} ({detail}); choose another Workspace",
response.workspace_id
);
target.clear_workspace();
continue;
}
}
dedup_workers(&mut response.items);
if response.items.is_empty() {
let diagnostics = response
.diagnostics
.iter()
.map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message))
.collect::<Vec<_>>()
.join("; ");
let detail = if diagnostics.is_empty() {
"no backend diagnostics".to_string()
} else {
diagnostics
};
return Err(Box::new(io::Error::other(format!(
"Backend returned no runtime workers for workspace {} ({detail})",
response.workspace_id
))));
}
let selected = pick_worker(target.clone(), response.items)?;
let worker = if selected.state == "stopped" {
let restore_target = BackendRuntimeTarget::new(
target.base_url.clone(),
selected.runtime_id.clone(),
selected.worker_id.clone(),
);
restore_backend_worker(&restore_target)
.await
.map_err(|error| {
io::Error::other(format!(
"failed to restore Backend worker {}/{}: {error}",
selected.runtime_id, selected.worker_id
))
})?
.result
.worker
.unwrap_or(selected)
} else {
selected
};
let attach_target =
BackendRuntimeTarget::new(target.base_url, worker.runtime_id, worker.worker_id);
console::run_backend_runtime(attach_target).await
let selected = match pick_worker(target.clone(), response.items)? {
WorkerPickerResult::SwitchWorkspace => {
target.clear_workspace();
continue;
}
WorkerPickerResult::Selected(selected) => selected,
};
let worker = if selected.state == "stopped" {
let restore_target = target
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
.map_err(|error| io::Error::other(error.to_string()))?;
restore_backend_worker(&restore_target)
.await
.map_err(|error| {
io::Error::other(format!(
"failed to restore Backend worker {}/{}: {error}",
selected.runtime_id, selected.worker_id
))
})?
.result
.worker
.unwrap_or(selected)
} else {
selected
};
let attach_target = target
.runtime_target(worker.runtime_id, worker.worker_id)
.map_err(|error| io::Error::other(error.to_string()))?;
return console::run_backend_runtime(attach_target).await;
}
}
fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) {
@@ -92,10 +109,15 @@ fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) {
workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone())));
}
enum WorkerPickerResult {
Selected(BackendWorkerSummary),
SwitchWorkspace,
}
fn pick_worker(
target: BackendRuntimeListTarget,
mut workers: Vec<BackendWorkerSummary>,
) -> Result<BackendWorkerSummary, Box<dyn Error>> {
) -> Result<WorkerPickerResult, Box<dyn Error>> {
workers.sort_by(|a, b| {
a.runtime_id
.cmp(&b.runtime_id)
@@ -114,7 +136,13 @@ fn pick_worker(
Some(Action::Down) => state.next(),
Some(Action::Submit) => {
close_viewport(&mut terminal)?;
return Ok(state.selected_worker().clone());
return Ok(WorkerPickerResult::Selected(
state.selected_worker().clone(),
));
}
Some(Action::SwitchWorkspace) => {
close_viewport(&mut terminal)?;
return Ok(WorkerPickerResult::SwitchWorkspace);
}
Some(Action::Cancel) => {
close_viewport(&mut terminal)?;
@@ -181,6 +209,7 @@ enum Action {
Up,
Down,
Submit,
SwitchWorkspace,
Cancel,
}
@@ -197,6 +226,7 @@ fn poll_event() -> io::Result<Option<Action>> {
KeyCode::Char('k') if !ctrl => Some(Action::Up),
KeyCode::Char('j') if !ctrl => Some(Action::Down),
KeyCode::Enter => Some(Action::Submit),
KeyCode::Char('w') if !ctrl => Some(Action::SwitchWorkspace),
KeyCode::Esc => Some(Action::Cancel),
KeyCode::Char('c') if ctrl => Some(Action::Cancel),
_ => None,
@@ -239,6 +269,8 @@ fn draw(frame: &mut Frame<'_>, state: &BackendWorkerPickerState) {
Span::raw(" select "),
Span::styled("[enter]", Style::default().fg(Color::Green)),
Span::raw(" attach "),
Span::styled("[w]", Style::default().fg(Color::Cyan)),
Span::raw(" switch Workspace "),
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
Span::raw(" cancel"),
])),
+240
View File
@@ -0,0 +1,240 @@
use client::{
BackendWorkspace, BackendWorkspaceCatalogTarget, CreateBackendWorkspaceRepository,
CreateBackendWorkspaceRequest, create_backend_workspace, list_backend_workspaces,
};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use std::error::Error;
use std::io::{self, IsTerminal, Write};
use std::time::{SystemTime, UNIX_EPOCH};
type PickerResult<T> = Result<T, Box<dyn Error>>;
pub(crate) async fn select_backend_workspace(base_url: &str) -> PickerResult<Option<String>> {
let target = BackendWorkspaceCatalogTarget::new(base_url);
let mut workspaces = Vec::new();
'catalog: loop {
let error = match list_backend_workspaces(&target).await {
Ok(items) => {
workspaces = items;
None
}
Err(fetch_error) => Some(format!("failed to refresh workspaces: {fetch_error}")),
};
match pick_workspace(&workspaces, error.as_deref())? {
WorkspacePickerAction::Select(index) => {
return Ok(workspaces.get(index).map(|item| item.workspace_id.clone()));
}
WorkspacePickerAction::Refresh => continue,
WorkspacePickerAction::Create => {
let Some(request) = prompt_create_request()? else {
continue;
};
loop {
match create_backend_workspace(&target, &request).await {
Ok(response) => return Ok(Some(response.workspace.workspace_id)),
Err(create_error) => {
let creation_error =
format!("workspace creation failed: {create_error}");
match pick_workspace(&workspaces, Some(&creation_error))? {
WorkspacePickerAction::Select(index) => {
return Ok(workspaces
.get(index)
.map(|item| item.workspace_id.clone()));
}
// Retry the exact request and operation key.
WorkspacePickerAction::Create => continue,
WorkspacePickerAction::Refresh => continue 'catalog,
WorkspacePickerAction::Cancel => return Ok(None),
}
}
}
}
}
WorkspacePickerAction::Cancel => return Ok(None),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WorkspacePickerAction {
Select(usize),
Create,
Refresh,
Cancel,
}
fn pick_workspace(
workspaces: &[BackendWorkspace],
error: Option<&str>,
) -> PickerResult<WorkspacePickerAction> {
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return Err(
"Backend target has no configured workspace; an interactive terminal is required to choose one"
.into(),
);
}
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
let mut selected = 0usize;
loop {
terminal.draw(|frame| {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(if error.is_some() { 3 } else { 1 }),
])
.split(frame.area());
frame.render_widget(
Paragraph::new("Choose the Workspace for this Backend session")
.block(Block::default().title("Workspace").borders(Borders::ALL)),
chunks[0],
);
let rows = workspaces
.iter()
.map(|workspace| {
ListItem::new(Line::from(vec![
Span::styled(
workspace.display_name.clone(),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!(" {} {}", workspace.workspace_id, workspace.state)),
]))
})
.collect::<Vec<_>>();
let rows = if rows.is_empty() {
vec![ListItem::new("No accessible Workspaces")]
} else {
rows
};
let mut state = ListState::default();
if !workspaces.is_empty() {
state.select(Some(selected));
}
frame.render_stateful_widget(
List::new(rows)
.block(Block::default().borders(Borders::ALL))
.highlight_symbol(""),
chunks[1],
&mut state,
);
let footer = error
.map(|message| {
format!(
"{message} [n] create/retry [r] refresh [Enter] select [Esc] cancel"
)
})
.unwrap_or_else(|| {
"[Enter] select [n] new [r] refresh [Esc] cancel".to_string()
});
frame.render_widget(Paragraph::new(footer), chunks[2]);
})?;
if let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
match key.code {
KeyCode::Up if !workspaces.is_empty() => {
selected = selected.saturating_sub(1);
}
KeyCode::Down if !workspaces.is_empty() => {
selected = (selected + 1).min(workspaces.len() - 1);
}
KeyCode::Enter if !workspaces.is_empty() => {
terminal.clear()?;
return Ok(WorkspacePickerAction::Select(selected));
}
KeyCode::Char('n') => {
terminal.clear()?;
return Ok(WorkspacePickerAction::Create);
}
KeyCode::Char('r') => {
terminal.clear()?;
return Ok(WorkspacePickerAction::Refresh);
}
KeyCode::Esc | KeyCode::Char('q') => {
terminal.clear()?;
return Ok(WorkspacePickerAction::Cancel);
}
_ => {}
}
}
}
}
fn prompt_create_request() -> PickerResult<Option<CreateBackendWorkspaceRequest>> {
disable_raw_mode()?;
let result = prompt_create_request_inner();
enable_raw_mode()?;
result
}
fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRequest>> {
println!("Create Workspace (leave display name empty to cancel)");
let display_name = prompt_line("Workspace display name: ")?;
if display_name.is_empty() {
return Ok(None);
}
let uri = prompt_line("Initial repository absolute path/URI: ")?;
if uri.is_empty() {
println!("Repository path/URI is required.");
return Ok(None);
}
let repository_name = prompt_line("Repository display name [Main]: ")?;
let default_ref = prompt_line("Default ref [repository default]: ")?;
let operation_key = format!(
"tui-workspace-create-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
Ok(Some(CreateBackendWorkspaceRequest {
operation_key,
display_name,
repository: CreateBackendWorkspaceRepository {
uri,
display_name: Some(if repository_name.is_empty() {
"Main".to_string()
} else {
repository_name
}),
default_ref: (!default_ref.is_empty()).then_some(default_ref),
},
}))
}
fn prompt_line(prompt: &str) -> PickerResult<String> {
print!("{prompt}");
io::stdout().flush()?;
let mut value = String::new();
io::stdin().read_line(&mut value)?;
Ok(value.trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn picker_actions_distinguish_switch_refresh_create_and_cancel() {
assert_ne!(
WorkspacePickerAction::Create,
WorkspacePickerAction::Refresh
);
assert_ne!(
WorkspacePickerAction::Select(0),
WorkspacePickerAction::Cancel
);
}
}
+1
View File
@@ -1,5 +1,6 @@
mod app;
mod backend_worker_picker;
mod backend_workspace_picker;
mod block;
mod cache;
mod command;