diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index 7f22b21d..9c810dcc 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -14,6 +14,8 @@ pub struct BackendRuntimeTarget { /// Workspace Backend API root URL, for example `http://127.0.0.1:8787`. /// This is intentionally the Backend endpoint, not a Runtime endpoint. pub base_url: String, + /// Workspace identity used for every Worker lifecycle and protocol operation. + pub workspace_id: String, /// Backend-owned Runtime identity used as path authority. pub runtime_id: String, /// Backend-owned Worker identity used as path authority. @@ -23,11 +25,13 @@ pub struct BackendRuntimeTarget { impl BackendRuntimeTarget { pub fn new( base_url: impl Into, + workspace_id: impl Into, runtime_id: impl Into, worker_id: impl Into, ) -> Self { Self { base_url: base_url.into(), + workspace_id: workspace_id.into(), runtime_id: runtime_id.into(), worker_id: worker_id.into(), } @@ -57,6 +61,36 @@ impl BackendRuntimeListTarget { runtime_id, } } + + pub fn select_workspace(&mut self, workspace_id: impl Into) { + self.workspace_id = Some(workspace_id.into()); + } + + pub fn clear_workspace(&mut self) { + self.workspace_id = None; + } + + pub fn workspace_id(&self) -> Option<&str> { + self.workspace_id.as_deref() + } + + pub fn runtime_target( + &self, + runtime_id: impl Into, + worker_id: impl Into, + ) -> Result { + let workspace_id = self.workspace_id.clone().ok_or_else(|| { + BackendRuntimeClientError::InvalidTarget( + "workspace_id is required before selecting a Backend worker".to_string(), + ) + })?; + Ok(BackendRuntimeTarget::new( + self.base_url.clone(), + workspace_id, + runtime_id, + worker_id, + )) + } } #[derive(Debug, Clone, Deserialize)] @@ -186,7 +220,13 @@ pub async fn list_backend_workers( validate_list_target(target)?; let http = reqwest::Client::new(); if let Some(runtime_id) = target.runtime_id.as_deref() { - let path = backend_runtime_workers_path(target.workspace_id.as_deref(), runtime_id); + let path = backend_runtime_workers_path( + target + .workspace_id + .as_deref() + .expect("validated Backend Workspace scope"), + runtime_id, + ); let url = join_base_and_path(&target.base_url, &path); return Ok(http .get(url) @@ -197,7 +237,12 @@ pub async fn list_backend_workers( .await?); } - let runtime_path = backend_runtimes_path(target.workspace_id.as_deref()); + let runtime_path = backend_runtimes_path( + target + .workspace_id + .as_deref() + .expect("validated Backend Workspace scope"), + ); let runtime_url = join_base_and_path(&target.base_url, &runtime_path); let runtimes = http .get(runtime_url) @@ -210,8 +255,13 @@ pub async fn list_backend_workers( let mut items = Vec::new(); let mut diagnostics = runtimes.diagnostics; for runtime in runtimes.items { - let path = - backend_runtime_workers_path(target.workspace_id.as_deref(), &runtime.runtime_id); + let path = backend_runtime_workers_path( + target + .workspace_id + .as_deref() + .expect("validated Backend Workspace scope"), + &runtime.runtime_id, + ); let url = join_base_and_path(&target.base_url, &path); match http .get(url) @@ -256,7 +306,13 @@ pub async fn list_backend_stopped_workers( )); }; let http = reqwest::Client::new(); - let path = backend_runtime_workers_path(target.workspace_id.as_deref(), runtime_id); + let path = backend_runtime_workers_path( + target + .workspace_id + .as_deref() + .expect("validated Backend Workspace scope"), + runtime_id, + ); let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped")); Ok(http .get(url) @@ -272,7 +328,11 @@ pub async fn restore_backend_worker( ) -> Result { validate_target(target)?; let http = reqwest::Client::new(); - let path = backend_runtime_worker_restore_path(None, &target.runtime_id, &target.worker_id); + let path = backend_runtime_worker_restore_path( + &target.workspace_id, + &target.runtime_id, + &target.worker_id, + ); let url = join_base_and_path(&target.base_url, &path); Ok(http .post(url) @@ -440,6 +500,11 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl "Backend API base URL must start with http:// or https://".to_string(), )); } + if target.workspace_id.is_empty() { + return Err(BackendRuntimeClientError::InvalidTarget( + "workspace_id is required".to_string(), + )); + } if target.runtime_id.is_empty() { return Err(BackendRuntimeClientError::InvalidTarget( "runtime_id is required".to_string(), @@ -466,10 +531,18 @@ fn validate_list_target( "Backend API base URL must start with http:// or https://".to_string(), )); } - if target.workspace_id.as_deref().is_some_and(str::is_empty) { - return Err(BackendRuntimeClientError::InvalidTarget( - "workspace_id must not be empty when provided".to_string(), - )); + match target.workspace_id.as_deref() { + Some("") => { + return Err(BackendRuntimeClientError::InvalidTarget( + "workspace_id must not be empty".to_string(), + )); + } + None => { + return Err(BackendRuntimeClientError::InvalidTarget( + "workspace selection is required before listing Backend workers".to_string(), + )); + } + Some(_) => {} } if target.runtime_id.as_deref().is_some_and(str::is_empty) { return Err(BackendRuntimeClientError::InvalidTarget( @@ -479,47 +552,35 @@ fn validate_list_target( Ok(()) } -fn backend_runtimes_path(workspace_id: Option<&str>) -> String { - match workspace_id { - Some(workspace_id) => format!("/api/w/{}/runtimes", path_segment_encode(workspace_id)), - None => "/api/runtimes".to_string(), - } +fn backend_runtimes_path(workspace_id: &str) -> String { + format!("/api/w/{}/runtimes", path_segment_encode(workspace_id)) } -fn backend_runtime_workers_path(workspace_id: Option<&str>, runtime_id: &str) -> String { - match workspace_id { - Some(workspace_id) => format!( - "/api/w/{}/runtimes/{}/workers", - path_segment_encode(workspace_id), - path_segment_encode(runtime_id) - ), - None => format!("/api/runtimes/{}/workers", path_segment_encode(runtime_id)), - } +fn backend_runtime_workers_path(workspace_id: &str, runtime_id: &str) -> String { + format!( + "/api/w/{}/runtimes/{}/workers", + path_segment_encode(workspace_id), + path_segment_encode(runtime_id) + ) } fn backend_runtime_worker_restore_path( - workspace_id: Option<&str>, + workspace_id: &str, runtime_id: &str, worker_id: &str, ) -> String { - match workspace_id { - Some(workspace_id) => format!( - "/api/w/{}/runtimes/{}/workers/{}/restore", - path_segment_encode(workspace_id), - path_segment_encode(runtime_id), - path_segment_encode(worker_id) - ), - None => format!( - "/api/runtimes/{}/workers/{}/restore", - path_segment_encode(runtime_id), - path_segment_encode(worker_id) - ), - } + format!( + "/api/w/{}/runtimes/{}/workers/{}/restore", + path_segment_encode(workspace_id), + path_segment_encode(runtime_id), + path_segment_encode(worker_id) + ) } fn protocol_ws_url(target: &BackendRuntimeTarget) -> String { let path = format!( - "/api/runtimes/{}/workers/{}/protocol/ws", + "/api/w/{}/runtimes/{}/workers/{}/protocol/ws", + path_segment_encode(&target.workspace_id), path_segment_encode(&target.runtime_id), path_segment_encode(&target.worker_id) ); @@ -573,11 +634,15 @@ mod tests { #[test] fn protocol_url_uses_backend_runtime_worker_identity() { - let target = - BackendRuntimeTarget::new("http://127.0.0.1:8787/", "runtime/one", "worker one"); + let target = BackendRuntimeTarget::new( + "http://127.0.0.1:8787/", + "workspace alpha", + "runtime/one", + "worker one", + ); assert_eq!( protocol_ws_url(&target), - "ws://127.0.0.1:8787/api/runtimes/runtime%2Fone/workers/worker%20one/protocol/ws" + "ws://127.0.0.1:8787/api/w/workspace%20alpha/runtimes/runtime%2Fone/workers/worker%20one/protocol/ws" ); } @@ -622,8 +687,8 @@ mod tests { } #[test] - fn workers_path_can_be_workspace_scoped_for_status_queries() { - let path = backend_runtime_workers_path(Some("team main"), "runtime/one"); + fn workers_path_requires_workspace_scope_for_status_queries() { + let path = backend_runtime_workers_path("team main", "runtime/one"); assert_eq!( format!("{path}?status=stopped"), "/api/w/team%20main/runtimes/runtime%2Fone/workers?status=stopped" @@ -631,10 +696,10 @@ mod tests { } #[test] - fn restore_worker_path_uses_backend_runtime_worker_identity() { + fn restore_worker_path_requires_workspace_scope() { assert_eq!( - backend_runtime_worker_restore_path(None, "runtime/one", "worker one"), - "/api/runtimes/runtime%2Fone/workers/worker%20one/restore" + backend_runtime_worker_restore_path("team main", "runtime/one", "worker one"), + "/api/w/team%20main/runtimes/runtime%2Fone/workers/worker%20one/restore" ); } } diff --git a/crates/client/src/backend_workspace.rs b/crates/client/src/backend_workspace.rs new file mode 100644 index 00000000..c6dee233 --- /dev/null +++ b/crates/client/src/backend_workspace.rs @@ -0,0 +1,161 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +const DEFAULT_WORKSPACE_LIMIT: usize = 200; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BackendWorkspace { + pub workspace_id: String, + pub owner_account_id: Option, + pub display_name: String, + pub state: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct CreateBackendWorkspaceRequest { + pub operation_key: String, + pub display_name: String, + pub repository: CreateBackendWorkspaceRepository, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct CreateBackendWorkspaceRepository { + pub uri: String, + pub display_name: Option, + pub default_ref: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct CreateBackendWorkspaceResponse { + pub workspace: BackendWorkspace, + pub repository: CreateBackendWorkspaceRepositoryRecord, + pub config_revision: u64, + pub request_fingerprint: String, + pub replayed: bool, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct CreateBackendWorkspaceRepositoryRecord { + pub workspace_id: String, + pub repository_id: String, + pub name: String, + pub kind: String, + pub uri: String, + pub default_ref: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendWorkspaceCatalogTarget { + pub base_url: String, +} + +impl BackendWorkspaceCatalogTarget { + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + } + } +} + +#[derive(Debug)] +pub enum BackendWorkspaceClientError { + InvalidTarget(String), + RequestFailed { status: u16, message: String }, + Http(reqwest::Error), +} + +impl fmt::Display for BackendWorkspaceClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidTarget(message) => f.write_str(message), + Self::RequestFailed { status, message } => { + write!(f, "Backend request failed with HTTP {status}: {message}") + } + Self::Http(error) => write!(f, "{error}"), + } + } +} + +impl std::error::Error for BackendWorkspaceClientError {} + +impl From for BackendWorkspaceClientError { + fn from(error: reqwest::Error) -> Self { + Self::Http(error) + } +} + +pub async fn list_backend_workspaces( + target: &BackendWorkspaceCatalogTarget, +) -> Result, BackendWorkspaceClientError> { + validate_target(target)?; + let url = format!( + "{}/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}", + target.base_url.trim_end_matches('/') + ); + let response = reqwest::Client::new().get(url).send().await?; + let response = require_success(response).await?; + Ok(response.json::>().await?) +} + +pub async fn create_backend_workspace( + target: &BackendWorkspaceCatalogTarget, + request: &CreateBackendWorkspaceRequest, +) -> Result { + validate_target(target)?; + let url = format!("{}/api/workspaces", target.base_url.trim_end_matches('/')); + let response = reqwest::Client::new() + .post(url) + .json(request) + .send() + .await?; + let response = require_success(response).await?; + Ok(response.json::().await?) +} + +async fn require_success( + response: reqwest::Response, +) -> Result { + if response.status().is_success() { + return Ok(response); + } + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + Err(BackendWorkspaceClientError::RequestFailed { status, message }) +} + +fn validate_target( + target: &BackendWorkspaceCatalogTarget, +) -> Result<(), BackendWorkspaceClientError> { + if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) { + return Err(BackendWorkspaceClientError::InvalidTarget( + "Backend API base URL must start with http:// or https://".to_string(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_request_keeps_operation_key_for_exact_retry() { + let request = CreateBackendWorkspaceRequest { + operation_key: "workspace-create-1".to_string(), + display_name: "Alpha".to_string(), + repository: CreateBackendWorkspaceRepository { + uri: "/srv/repos/alpha".to_string(), + display_name: Some("Main".to_string()), + default_ref: Some("develop".to_string()), + }, + }; + + let retry = request.clone(); + assert_eq!(retry.operation_key, "workspace-create-1"); + assert_eq!(retry, request); + } +} diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index cab1b245..45d51a90 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -10,6 +10,7 @@ pub mod backend_auth; pub mod backend_runtime; +pub mod backend_workspace; pub mod runtime_command; pub mod spawn; pub mod target; @@ -28,6 +29,11 @@ pub use backend_runtime::{ BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers, list_backend_workers, restore_backend_worker, }; +pub use backend_workspace::{ + BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError, + CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest, + CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces, +}; pub use runtime_command::WorkerRuntimeCommand; pub use target::{ BackendTarget, Dashboard, LocalTarget, Target, TargetError, TargetKind, WorkerByName, diff --git a/crates/client/src/target.rs b/crates/client/src/target.rs index 595aeff3..1234d459 100644 --- a/crates/client/src/target.rs +++ b/crates/client/src/target.rs @@ -132,6 +132,12 @@ impl TargetError { } } + fn invalid(target: TargetKind, message: impl Into) -> Self { + Self { + message: format!("invalid {target} target: {}", message.into()), + } + } + fn local_runtime_command(error: std::io::Error) -> Self { Self { message: format!("failed to resolve local Worker runtime command: {error}"), @@ -260,9 +266,16 @@ impl Target for BackendTarget { &self, selector: WorkerConnectionSelector, ) -> Result { + let workspace_id = self.workspace_id.clone().ok_or_else(|| { + TargetError::invalid( + self.kind(), + "workspace selection is required before connecting to a Backend Worker", + ) + })?; Ok(WorkerConnection { target: BackendRuntimeTarget::new( self.base_url.clone(), + workspace_id, selector.runtime_id, selector.worker_id, ), @@ -313,10 +326,27 @@ mod tests { .unwrap(); assert_eq!(connection.target.base_url, "http://127.0.0.1:8787"); + assert_eq!(connection.target.workspace_id, "workspace-a"); assert_eq!(connection.target.runtime_id, "runtime-a"); assert_eq!(connection.target.worker_id, "worker-b"); } + #[test] + fn backend_target_rejects_worker_connection_before_workspace_selection() { + let target = BackendTarget::new("http://127.0.0.1:8787", None::); + let error = + match target.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-b")) { + Ok(_) => panic!("unscoped connection must fail"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("workspace selection is required") + ); + } + #[test] fn backend_target_rejects_local_worker_operations() { let target = BackendTarget::new("http://127.0.0.1:8787", None::); diff --git a/crates/client/tests/backend_workspace.rs b/crates/client/tests/backend_workspace.rs new file mode 100644 index 00000000..64772bd2 --- /dev/null +++ b/crates/client/tests/backend_workspace.rs @@ -0,0 +1,36 @@ +use client::{ + BackendTarget, CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest, Target, + WorkerConnectionSelector, +}; + +#[test] +fn workspace_creation_request_preserves_operation_key_for_retry() { + let request = CreateBackendWorkspaceRequest { + operation_key: "workspace-create-1".to_string(), + display_name: "Alpha".to_string(), + repository: CreateBackendWorkspaceRepository { + uri: "/srv/repos/alpha".to_string(), + display_name: Some("Main".to_string()), + default_ref: Some("develop".to_string()), + }, + }; + + assert_eq!(request.clone(), request); + assert_eq!(request.operation_key, "workspace-create-1"); +} + +#[test] +fn backend_worker_connection_requires_explicit_workspace_scope() { + let target = BackendTarget::new("http://127.0.0.1:8787", None::); + let error = match target.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-a")) + { + Ok(_) => panic!("unscoped Backend worker connection must fail"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("workspace selection is required") + ); +} diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index c4bd1292..5ff2d925 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -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> { - 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::>() + .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::>() - .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) { @@ -92,10 +109,15 @@ fn dedup_workers(workers: &mut Vec) { 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, -) -> Result> { +) -> Result> { 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> { 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"), ])), diff --git a/crates/tui/src/backend_workspace_picker.rs b/crates/tui/src/backend_workspace_picker.rs new file mode 100644 index 00000000..e64cc71f --- /dev/null +++ b/crates/tui/src/backend_workspace_picker.rs @@ -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 = Result>; + +pub(crate) async fn select_backend_workspace(base_url: &str) -> PickerResult> { + 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 { + 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::>(); + 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> { + disable_raw_mode()?; + let result = prompt_create_request_inner(); + enable_raw_mode()?; + result +} + +fn prompt_create_request_inner() -> PickerResult> { + 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 { + 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 + ); + } +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 425a7c29..fdc25345 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -1,5 +1,6 @@ mod app; mod backend_worker_picker; +mod backend_workspace_picker; mod block; mod cache; mod command;