#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
mod snapshot;
use bmux_clients_plugin_api::clients_state;
use bmux_contexts_plugin_api::{contexts_commands, contexts_state};
use bmux_plugin::{
HostRuntimeApi, ServiceCaller, TypedServiceCaller, global_event_bus,
global_plugin_state_registry,
};
use bmux_plugin_sdk::prelude::*;
use bmux_plugin_sdk::{
StorageGetRequest, StorageSetRequest, TypedServiceRegistrationContext, TypedServiceRegistry,
};
use bmux_workspaces_plugin_api::workspaces_activation_v1::{self, WorkspacesActivationV1Service};
use bmux_workspaces_plugin_api::workspaces_commands::{
self, WorkspaceAck, WorkspaceCommandError, WorkspacesCommandsService,
};
use bmux_workspaces_plugin_api::workspaces_events::{self, WorkspaceEvent};
use bmux_workspaces_plugin_api::workspaces_state::{
self, WorkspaceQueryError, WorkspaceSelector, WorkspaceSummary, WorkspacesStateService,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use uuid::Uuid;
const DEFAULT_WORKSPACE_NAME: &str = "default";
const DEFAULT_WORKSPACE_ATTRIBUTE: &str = "default";
const SELECTED_CONTEXT_OUTCOME_KEY: &str = "bmux.contexts.selected_context_id";
const ACTIVE_BY_CLIENT_KEY: &str = "workspaces.active_by_client";
const PREVIOUS_BY_CLIENT_KEY: &str = "workspaces.previous_by_client";
const HISTORY_KEY: &str = "workspaces.navigation_history.v1";
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct NavigationHistory {
version: u32,
active_by_client: HashMap<Uuid, Uuid>,
previous_by_client: HashMap<Uuid, Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct WorkspaceRecord {
id: Uuid,
name: String,
}
#[derive(Debug, Default)]
struct WorkspaceState {
records: Vec<WorkspaceRecord>,
active_by_client: HashMap<Uuid, Uuid>,
previous_by_client: HashMap<Uuid, Uuid>,
selected_context_by_client_workspace: HashMap<(Uuid, Uuid), Uuid>,
}
impl WorkspaceState {
fn ensure_default(&mut self) {
if self.records.is_empty() {
self.records.push(WorkspaceRecord {
id: Uuid::nil(),
name: DEFAULT_WORKSPACE_NAME.to_string(),
});
}
}
fn resolve(&self, selector: &WorkspaceSelector) -> Option<&WorkspaceRecord> {
selector
.id
.and_then(|id| self.records.iter().find(|workspace| workspace.id == id))
.or_else(|| {
selector
.name
.as_deref()
.and_then(|name| self.records.iter().find(|workspace| workspace.name == name))
})
}
fn active_id(&self, client_id: Uuid) -> Uuid {
self.active_by_client
.get(&client_id)
.copied()
.filter(|id| self.records.iter().any(|workspace| workspace.id == *id))
.unwrap_or_else(Uuid::nil)
}
fn select(&mut self, client_id: Uuid, workspace_id: Uuid) {
let current = self.active_id(client_id);
if current != workspace_id {
self.previous_by_client.insert(client_id, current);
self.active_by_client.insert(client_id, workspace_id);
}
}
fn remove(&mut self, workspace_id: Uuid) -> Uuid {
let fallback_id = self
.records
.iter()
.find(|record| record.id != workspace_id)
.map(|record| record.id);
let fallback_id = if let Some(fallback_id) = fallback_id {
fallback_id
} else {
let replacement_id = Uuid::new_v4();
self.records.push(WorkspaceRecord {
id: replacement_id,
name: DEFAULT_WORKSPACE_NAME.to_string(),
});
replacement_id
};
self.records.retain(|record| record.id != workspace_id);
for active in self.active_by_client.values_mut() {
if *active == workspace_id {
*active = fallback_id;
}
}
for previous in self.previous_by_client.values_mut() {
if *previous == workspace_id {
*previous = fallback_id;
}
}
fallback_id
}
}
#[derive(Default)]
pub struct WorkspacesPlugin;
impl RustPlugin for WorkspacesPlugin {
type Contract = bmux_workspaces_plugin_api::Contract;
fn activate(&mut self, context: NativeLifecycleContext) -> Result<i32, PluginCommandError> {
let records = load_catalog(&context).map_err(PluginCommandError::failed)?;
let history = load_navigation_history(&context).map_err(PluginCommandError::failed)?;
let active_by_client = history.active_by_client;
let previous_by_client = history.previous_by_client;
let mut state = WorkspaceState {
records,
active_by_client,
previous_by_client,
selected_context_by_client_workspace: HashMap::new(),
};
state.ensure_default();
save_catalog(&context, &state.records)
.map_err(|error| PluginCommandError::failed(format!("{error:?}")))?;
let state = Arc::new(RwLock::new(state));
snapshot::register(&context, Arc::clone(&state)).map_err(PluginCommandError::failed)?;
global_plugin_state_registry().register::<WorkspaceState>(&state);
global_event_bus().register_channel::<WorkspaceEvent>(workspaces_events::EVENT_KIND);
Ok(EXIT_OK)
}
fn run_command(&mut self, context: NativeCommandContext) -> Result<i32, PluginCommandError> {
run_command(&context).map_err(PluginCommandError::failed)?;
Ok(EXIT_OK)
}
fn invoke_service(&self, context: NativeServiceContext) -> ServiceResponse {
bmux_plugin_sdk::route_service!(context, {
"workspaces-state", "list-workspaces" => |_req: (), ctx| {
list_workspaces(ctx).map_err(|error| ServiceResponse::error("list_failed", error))
},
"workspaces-state", "get-workspace" => |req: SelectorArgs, ctx| {
Ok::<Result<WorkspaceSummary, WorkspaceQueryError>, ServiceResponse>(
get_workspace(ctx, &req.selector)
)
},
"workspaces-state", "current-workspace" => |_req: (), ctx| {
current_workspace(ctx).map_err(|error| ServiceResponse::error("current_failed", error))
},
"workspaces-commands", "new-workspace" => |req: NewWorkspaceArgs, ctx| {
Ok::<Result<WorkspaceAck, WorkspaceCommandError>, ServiceResponse>(
new_workspace(ctx, req.name)
)
},
"workspaces-commands", "rename-workspace" => |req: RenameWorkspaceArgs, ctx| {
Ok::<Result<WorkspaceAck, WorkspaceCommandError>, ServiceResponse>(
rename_workspace(ctx, &req.selector, req.name)
)
},
"workspaces-commands", "kill-workspace" => |req: SelectorArgs, ctx| {
Ok::<Result<WorkspaceAck, WorkspaceCommandError>, ServiceResponse>(
kill_workspace(ctx, &req.selector)
)
},
"workspaces-activation-v1", "activate-tab" => |req: workspaces_activation_v1::client::ActivateTabRequest, ctx| {
Ok::<Result<WorkspaceAck, WorkspaceCommandError>, ServiceResponse>(
client_id(ctx.caller_client_id).and_then(|id| activate_tab_for_client(ctx, req.context_id, id))
)
},
"workspaces-commands", "switch-workspace" => |req: SelectorArgs, ctx| {
Ok::<Result<WorkspaceAck, WorkspaceCommandError>, ServiceResponse>(
switch_workspace(ctx, &req.selector)
)
},
"workspaces-commands", "next-workspace" => |_req: (), ctx| {
Ok::<Result<WorkspaceAck, WorkspaceCommandError>, ServiceResponse>(
cycle_workspace(ctx, 1)
)
},
"workspaces-commands", "prev-workspace" => |_req: (), ctx| {
Ok::<Result<WorkspaceAck, WorkspaceCommandError>, ServiceResponse>(
cycle_workspace(ctx, -1)
)
},
"workspaces-commands", "last-workspace" => |_req: (), ctx| {
Ok::<Result<WorkspaceAck, WorkspaceCommandError>, ServiceResponse>(
last_workspace(ctx)
)
},
"workspaces-commands", "move-tab-to-workspace" => |req: MoveTabArgs, ctx| {
Ok::<Result<WorkspaceAck, WorkspaceCommandError>, ServiceResponse>(
move_tab(ctx, req.context_id, &req.workspace)
)
},
})
}
fn register_typed_services(
&self,
context: TypedServiceRegistrationContext<'_>,
registry: &mut TypedServiceRegistry,
) {
let caller = Arc::new(TypedServiceCaller::from_registration_context(&context));
let state: Arc<dyn WorkspacesStateService + Send + Sync> =
Arc::new(WorkspacesStateHandle {
caller: Arc::clone(&caller),
});
let activation: Arc<dyn WorkspacesActivationV1Service + Send + Sync> =
Arc::new(WorkspacesCommandsHandle {
caller: caller.clone(),
});
let _ = workspaces_activation_v1::register_provider(registry, activation);
let commands: Arc<dyn WorkspacesCommandsService + Send + Sync> =
Arc::new(WorkspacesCommandsHandle { caller });
let _ = workspaces_state::register_provider(registry, state);
let _ = workspaces_commands::register_provider(registry, commands);
}
}
#[derive(Serialize, Deserialize)]
struct SelectorArgs {
selector: WorkspaceSelector,
}
#[derive(Serialize, Deserialize)]
struct NewWorkspaceArgs {
name: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct RenameWorkspaceArgs {
selector: WorkspaceSelector,
name: String,
}
#[derive(Serialize, Deserialize)]
struct MoveTabArgs {
context_id: Uuid,
workspace: WorkspaceSelector,
}
fn state() -> Result<Arc<RwLock<WorkspaceState>>, String> {
global_plugin_state_registry()
.get::<WorkspaceState>()
.ok_or_else(|| "workspace state is not active".to_string())
}
const fn dispatch_client<C: ServiceCaller + Sync + ?Sized>(
caller: &C,
) -> bmux_plugin::ServiceCallerDispatchClient<'_, C> {
bmux_plugin::ServiceCallerDispatchClient::new(caller)
}
fn client_id(caller_client_id: Option<Uuid>) -> Result<Uuid, WorkspaceCommandError> {
caller_client_id.ok_or_else(|| WorkspaceCommandError::Failed {
reason: "workspace operation requires a caller client id".to_string(),
})
}
fn validate_name(name: Option<String>, ordinal: usize) -> Result<String, WorkspaceCommandError> {
let name = name.unwrap_or_else(|| format!("workspace-{ordinal}"));
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(WorkspaceCommandError::InvalidName {
reason: "name must not be empty".to_string(),
});
}
Ok(trimmed.to_string())
}
fn workspace_attribute(id: Uuid) -> String {
if id.is_nil() {
DEFAULT_WORKSPACE_ATTRIBUTE.to_string()
} else {
id.to_string()
}
}
fn context_workspace_id(context: &contexts_state::ContextSummary) -> Uuid {
context
.attributes
.get("workspace")
.filter(|value| value.as_str() != DEFAULT_WORKSPACE_ATTRIBUTE)
.and_then(|value| Uuid::parse_str(value).ok())
.unwrap_or_else(Uuid::nil)
}
fn list_contexts(
caller: &(impl ServiceCaller + Sync),
) -> Result<Vec<contexts_state::ContextSummary>, String> {
let mut client = dispatch_client(caller);
bmux_plugin::block_on_typed_dispatch(contexts_state::client::list_contexts(&mut client))
.map_err(|error| format!("contexts-state/list-contexts failed: {error}"))
}
fn create_workspace_context(
caller: &(impl ServiceCaller + Sync),
workspace_id: Uuid,
) -> Result<Uuid, WorkspaceCommandError> {
let mut client = dispatch_client(caller);
bmux_plugin::block_on_typed_dispatch(contexts_commands::client::create_context(
&mut client,
Some("tab-1".to_string()),
std::collections::BTreeMap::from([(
"workspace".to_string(),
workspace_attribute(workspace_id),
)]),
))
.map_err(|error| WorkspaceCommandError::Failed {
reason: format!("contexts-commands/create-context failed: {error}"),
})?
.map(|ack| ack.id)
.map_err(|error| WorkspaceCommandError::Failed {
reason: format!("create-context failed: {error:?}"),
})
}
fn select_context(
caller: &(impl ServiceCaller + Sync),
context_id: Uuid,
) -> Result<(), WorkspaceCommandError> {
let mut client = dispatch_client(caller);
let result = bmux_plugin::block_on_typed_dispatch(contexts_commands::client::select_context(
&mut client,
contexts_state::ContextSelector {
id: Some(context_id),
name: None,
},
))
.map_err(|error| WorkspaceCommandError::Failed {
reason: format!("contexts-commands/select-context failed: {error}"),
})?;
result
.map(|_| ())
.map_err(|error| WorkspaceCommandError::Failed {
reason: format!("select-context failed: {error:?}"),
})
}
fn set_context_workspace(
caller: &(impl ServiceCaller + Sync),
context: &contexts_state::ContextSummary,
workspace_id: Uuid,
) -> Result<(), WorkspaceCommandError> {
let mut client = dispatch_client(caller);
bmux_plugin::block_on_typed_dispatch(
bmux_tabs_plugin_api::tabs_placement_v1::client::place_tab(
&mut client,
context.id,
workspace_id,
),
)
.map_err(|error| WorkspaceCommandError::Failed {
reason: format!("tab placement unavailable: {error}"),
})?
.map_err(|reason| WorkspaceCommandError::Failed { reason })
}
fn effective_workspace(
selection: Option<Uuid>,
contexts: &[contexts_state::ContextSummary],
history: Uuid,
) -> Result<Uuid, String> {
selection.map_or(Ok(history), |id| {
contexts
.iter()
.find(|context| context.id == id)
.map(context_workspace_id)
.ok_or_else(|| {
"selected context is unavailable; workspace selection requires recovery".to_string()
})
})
}
fn selected_workspace(
caller: &(impl ServiceCaller + Sync),
contexts: &[contexts_state::ContextSummary],
caller_client_id: Option<Uuid>,
history: Uuid,
) -> Result<Uuid, String> {
let Some(caller_id) = caller_client_id else {
return Ok(history);
};
let mut client = dispatch_client(caller);
let summary =
bmux_plugin::block_on_typed_dispatch(clients_state::client::current_client(&mut client))
.map_err(|error| format!("reading authoritative client selection: {error}"))?
.map_err(|error| format!("reading authoritative client selection: {error:?}"))?;
if summary.id != caller_id {
return Err("client selection identity mismatch".to_string());
}
effective_workspace(summary.selected_context_id, contexts, history)
}
fn list_workspaces(caller: &(impl ServiceCaller + Sync)) -> Result<Vec<WorkspaceSummary>, String> {
list_workspaces_for_client(caller, resolve_client_id(caller))
}
fn resolve_client_id(caller: &(impl ServiceCaller + Sync)) -> Option<Uuid> {
let mut client = dispatch_client(caller);
bmux_plugin::block_on_typed_dispatch(clients_state::client::current_client(&mut client))
.ok()
.and_then(Result::ok)
.map(|summary| summary.id)
}
fn list_workspaces_for_client(
caller: &(impl ServiceCaller + Sync),
caller_client_id: Option<Uuid>,
) -> Result<Vec<WorkspaceSummary>, String> {
let contexts = list_contexts(caller)?;
let state = state()?;
let (records, history) = {
let guard = state
.read()
.map_err(|_| "workspace state lock poisoned".to_string())?;
(
guard.records.clone(),
caller_client_id.map_or_else(Uuid::nil, |id| guard.active_id(id)),
)
};
let active = selected_workspace(caller, &contexts, caller_client_id, history)?;
if !records.iter().any(|record| record.id == active) {
return Err("selected workspace is unavailable; recovery required".to_string());
}
Ok(records
.iter()
.map(|workspace| WorkspaceSummary {
id: workspace.id,
name: workspace.name.clone(),
tab_ids: contexts
.iter()
.filter(|context| context_workspace_id(context) == workspace.id)
.map(|context| context.id)
.collect(),
active: workspace.id == active,
})
.collect())
}
fn get_workspace(
caller: &(impl ServiceCaller + Sync),
selector: &WorkspaceSelector,
) -> Result<WorkspaceSummary, WorkspaceQueryError> {
list_workspaces(caller)
.map_err(|reason| WorkspaceQueryError::InvalidSelector { reason })?
.into_iter()
.find(|workspace| {
selector.id == Some(workspace.id)
|| selector.name.as_deref() == Some(workspace.name.as_str())
})
.ok_or(WorkspaceQueryError::NotFound)
}
fn current_workspace(
caller: &(impl ServiceCaller + Sync),
) -> Result<Option<WorkspaceSummary>, String> {
Ok(list_workspaces(caller)?
.into_iter()
.find(|workspace| workspace.active))
}
fn new_workspace(
caller: &(impl HostRuntimeApi + Sync),
name: Option<String>,
) -> Result<WorkspaceAck, WorkspaceCommandError> {
let client_id = client_id(resolve_client_id(caller))?;
new_workspace_for_client(caller, name, client_id)
}
fn new_workspace_for_client(
caller: &(impl HostRuntimeApi + Sync),
name: Option<String>,
client_id: Uuid,
) -> Result<WorkspaceAck, WorkspaceCommandError> {
let state = state().map_err(|reason| WorkspaceCommandError::Failed { reason })?;
let record = {
let mut guard = state.write().map_err(|_| WorkspaceCommandError::Failed {
reason: "workspace state lock poisoned".to_string(),
})?;
let name = validate_name(name, guard.records.len().saturating_add(1))?;
let record = WorkspaceRecord {
id: Uuid::new_v4(),
name,
};
persist_new_workspace(caller, &mut guard, record.clone())?;
drop(guard);
record
};
let _ = global_event_bus().emit(
&workspaces_events::EVENT_KIND,
WorkspaceEvent::Created {
workspace_id: record.id,
name: record.name,
},
);
switch_workspace_for_client(
caller,
&WorkspaceSelector {
id: Some(record.id),
name: None,
},
client_id,
)
}
fn rename_workspace(
caller: &(impl HostRuntimeApi + Sync),
selector: &WorkspaceSelector,
name: String,
) -> Result<WorkspaceAck, WorkspaceCommandError> {
let state = state().map_err(|reason| WorkspaceCommandError::Failed { reason })?;
let (id, name, records) = {
let mut guard = state.write().map_err(|_| WorkspaceCommandError::Failed {
reason: "workspace state lock poisoned".to_string(),
})?;
let id = guard
.resolve(selector)
.map(|record| record.id)
.ok_or(WorkspaceCommandError::NotFound)?;
let name = validate_name(Some(name), guard.records.len())?;
let record = guard
.records
.iter_mut()
.find(|record| record.id == id)
.ok_or(WorkspaceCommandError::NotFound)?;
record.name.clone_from(&name);
(id, name, guard.records.clone())
};
save_catalog(caller, &records)?;
let _ = global_event_bus().emit(
&workspaces_events::EVENT_KIND,
WorkspaceEvent::Renamed {
workspace_id: id,
name,
},
);
Ok(WorkspaceAck {
id,
selected_context_id: None,
})
}
fn kill_workspace(
caller: &(impl HostRuntimeApi + Sync),
selector: &WorkspaceSelector,
) -> Result<WorkspaceAck, WorkspaceCommandError> {
let contexts =
list_contexts(caller).map_err(|reason| WorkspaceCommandError::Failed { reason })?;
let state = state().map_err(|reason| WorkspaceCommandError::Failed { reason })?;
let (id, fallback_id, records) = {
let mut guard = state.write().map_err(|_| WorkspaceCommandError::Failed {
reason: "workspace state lock poisoned".to_string(),
})?;
let id = guard
.resolve(selector)
.map(|record| record.id)
.ok_or(WorkspaceCommandError::NotFound)?;
let fallback_id = guard.remove(id);
persist_client_selection(caller, &guard)?;
(id, fallback_id, guard.records.clone())
};
for context in contexts
.iter()
.filter(|context| context_workspace_id(context) == id)
{
set_context_workspace(caller, context, fallback_id)?;
}
save_catalog(caller, &records)?;
let _ = global_event_bus().emit(
&workspaces_events::EVENT_KIND,
WorkspaceEvent::Removed { workspace_id: id },
);
Ok(WorkspaceAck {
id,
selected_context_id: None,
})
}
fn switch_workspace(
caller: &(impl HostRuntimeApi + Sync),
selector: &WorkspaceSelector,
) -> Result<WorkspaceAck, WorkspaceCommandError> {
let client_id = client_id(resolve_client_id(caller))?;
switch_workspace_for_client(caller, selector, client_id)
}
fn most_recent_context_in_workspace(
contexts: &[contexts_state::ContextSummary],
workspace_id: Uuid,
) -> Option<Uuid> {
contexts
.iter()
.find(|context| context_workspace_id(context) == workspace_id)
.map(|context| context.id)
}
fn activate_tab_for_client(
caller: &(impl HostRuntimeApi + Sync),
context_id: Uuid,
client_id: Uuid,
) -> Result<WorkspaceAck, WorkspaceCommandError> {
let contexts =
list_contexts(caller).map_err(|reason| WorkspaceCommandError::Failed { reason })?;
let target = contexts
.iter()
.find(|context| context.id == context_id)
.ok_or(WorkspaceCommandError::NotFound)?;
let workspace_id = context_workspace_id(target);
select_workspace_target(
caller,
&WorkspaceSelector {
id: Some(workspace_id),
name: None,
},
client_id,
Some(context_id),
)
}
fn switch_workspace_for_client(
caller: &(impl HostRuntimeApi + Sync),
selector: &WorkspaceSelector,
client_id: Uuid,
) -> Result<WorkspaceAck, WorkspaceCommandError> {
select_workspace_target(caller, selector, client_id, None)
}
fn activate_exact_target(
caller: &(impl ServiceCaller + Sync),
context_id: Uuid,
) -> Result<(), WorkspaceCommandError> {
let mut client = dispatch_client(caller);
bmux_plugin::block_on_typed_dispatch(
bmux_contexts_plugin_api::contexts_activation_v1::client::activate(&mut client, context_id),
)
.map_err(|error| WorkspaceCommandError::Failed {
reason: error.to_string(),
})?
.map_err(|error| WorkspaceCommandError::Failed {
reason: format!("exact activation failed: {error:?}"),
})?;
Ok(())
}
fn select_workspace_target(
caller: &(impl HostRuntimeApi + Sync),
selector: &WorkspaceSelector,
client_id: Uuid,
exact_context_id: Option<Uuid>,
) -> Result<WorkspaceAck, WorkspaceCommandError> {
let contexts =
list_contexts(caller).map_err(|reason| WorkspaceCommandError::Failed { reason })?;
let state = state().map_err(|reason| WorkspaceCommandError::Failed { reason })?;
let mut context_client = dispatch_client(caller);
let current_context = bmux_plugin::block_on_typed_dispatch(
contexts_state::client::current_context(&mut context_client),
)
.ok()
.flatten();
let workspace_id = {
let mut guard = state.write().map_err(|_| WorkspaceCommandError::Failed {
reason: "workspace state lock poisoned".to_string(),
})?;
if let Some(context) = current_context {
guard
.selected_context_by_client_workspace
.insert((client_id, context_workspace_id(&context)), context.id);
}
guard
.resolve(selector)
.map(|record| record.id)
.ok_or(WorkspaceCommandError::NotFound)?
};
let remembered = state
.read()
.map_err(|_| WorkspaceCommandError::Failed {
reason: "workspace state lock poisoned".to_string(),
})?
.selected_context_by_client_workspace
.get(&(client_id, workspace_id))
.copied()
.filter(|id| {
contexts
.iter()
.any(|context| context.id == *id && context_workspace_id(context) == workspace_id)
});
let context_id = Some(if let Some(id) = exact_context_id {
if !contexts
.iter()
.any(|context| context.id == id && context_workspace_id(context) == workspace_id)
{
return Err(WorkspaceCommandError::NotFound);
}
id
} else {
match remembered.or_else(|| most_recent_context_in_workspace(&contexts, workspace_id)) {
Some(id) => id,
None => create_workspace_context(caller, workspace_id)?,
}
});
if let Some(context_id) = context_id {
if exact_context_id.is_some() {
activate_exact_target(caller, context_id)?;
} else {
select_context(caller, context_id)?;
}
}
let (active_by_client, previous_by_client) = {
let mut guard = state.write().map_err(|_| WorkspaceCommandError::Failed {
reason: "workspace state lock poisoned".to_string(),
})?;
guard.select(client_id, workspace_id);
(
guard.active_by_client.clone(),
guard.previous_by_client.clone(),
)
};
save_navigation_history(
caller,
&NavigationHistory {
version: 1,
active_by_client,
previous_by_client,
},
)?;
if let Some(context_id) = context_id {
let _ = global_event_bus().emit(
&workspaces_events::EVENT_KIND,
WorkspaceEvent::Selected {
workspace_id,
context_id,
initiator_client_id: Some(client_id),
},
);
}
Ok(WorkspaceAck {
id: workspace_id,
selected_context_id: context_id,
})
}
fn cycle_workspace(
caller: &(impl HostRuntimeApi + Sync),
offset: isize,
) -> Result<WorkspaceAck, WorkspaceCommandError> {
let client_id = client_id(resolve_client_id(caller))?;
let state = state().map_err(|reason| WorkspaceCommandError::Failed { reason })?;
let target = {
let guard = state.read().map_err(|_| WorkspaceCommandError::Failed {
reason: "workspace state lock poisoned".to_string(),
})?;
let current = guard.active_id(client_id);
let index = guard
.records
.iter()
.position(|record| record.id == current)
.unwrap_or(0);
let len = guard.records.len();
let target = if offset < 0 {
index
.checked_sub(1)
.unwrap_or_else(|| len.saturating_sub(1))
} else {
index.saturating_add(1) % len
};
guard.records[target].id
};
switch_workspace_for_client(
caller,
&WorkspaceSelector {
id: Some(target),
name: None,
},
client_id,
)
}
fn last_workspace(
caller: &(impl HostRuntimeApi + Sync),
) -> Result<WorkspaceAck, WorkspaceCommandError> {
let client_id = client_id(resolve_client_id(caller))?;
let state = state().map_err(|reason| WorkspaceCommandError::Failed { reason })?;
let previous = state
.read()
.map_err(|_| WorkspaceCommandError::Failed {
reason: "workspace state lock poisoned".to_string(),
})?
.previous_by_client
.get(&client_id)
.copied()
.ok_or(WorkspaceCommandError::NoPreviousWorkspace)?;
switch_workspace_for_client(
caller,
&WorkspaceSelector {
id: Some(previous),
name: None,
},
client_id,
)
}
fn move_tab(
caller: &(impl HostRuntimeApi + Sync),
context_id: Uuid,
selector: &WorkspaceSelector,
) -> Result<WorkspaceAck, WorkspaceCommandError> {
let workspace_id = {
let state = state().map_err(|reason| WorkspaceCommandError::Failed { reason })?;
let guard = state.read().map_err(|_| WorkspaceCommandError::Failed {
reason: "workspace state lock poisoned".to_string(),
})?;
guard
.resolve(selector)
.map(|record| record.id)
.ok_or(WorkspaceCommandError::NotFound)?
};
let context = list_contexts(caller)
.map_err(|reason| WorkspaceCommandError::Failed { reason })?
.into_iter()
.find(|context| context.id == context_id)
.ok_or(WorkspaceCommandError::NotFound)?;
set_context_workspace(caller, &context, workspace_id)?;
Ok(WorkspaceAck {
id: workspace_id,
selected_context_id: Some(context_id),
})
}
fn load_navigation_history(caller: &impl HostRuntimeApi) -> Result<NavigationHistory, String> {
let value = caller
.storage_get(&StorageGetRequest::new(
bmux_plugin_sdk::StorageKey::new(HISTORY_KEY).map_err(|error| error.to_string())?,
))
.map_err(|error| error.to_string())?
.value;
if let Some(value) = value {
let history: NavigationHistory =
serde_json::from_slice(&value).map_err(|error| error.to_string())?;
if history.version != 1 {
return Err(format!(
"unsupported workspace navigation history version {}",
history.version
));
}
return Ok(history);
}
let history = NavigationHistory {
version: 1,
active_by_client: load_client_workspace_map(caller, ACTIVE_BY_CLIENT_KEY)?,
previous_by_client: load_client_workspace_map(caller, PREVIOUS_BY_CLIENT_KEY)?,
};
save_navigation_history(caller, &history).map_err(|error| format!("{error:?}"))?;
Ok(history)
}
fn save_navigation_history(
caller: &impl HostRuntimeApi,
history: &NavigationHistory,
) -> Result<(), WorkspaceCommandError> {
let value = serde_json::to_vec(history).map_err(|error| WorkspaceCommandError::Failed {
reason: error.to_string(),
})?;
caller
.storage_set(&StorageSetRequest::new(
bmux_plugin_sdk::storage_key!("workspaces.navigation_history.v1"),
value,
))
.map_err(|error| WorkspaceCommandError::Failed {
reason: error.to_string(),
})
}
fn load_client_workspace_map(
caller: &impl HostRuntimeApi,
key: &str,
) -> Result<HashMap<Uuid, Uuid>, String> {
let response = caller
.storage_get(&StorageGetRequest::new(
bmux_plugin_sdk::StorageKey::new(key).map_err(|error| error.to_string())?,
))
.map_err(|error| error.to_string())?;
response.value.map_or_else(
|| Ok(HashMap::new()),
|value| serde_json::from_slice(&value).map_err(|error| error.to_string()),
)
}
fn persist_client_selection(
caller: &impl HostRuntimeApi,
state: &WorkspaceState,
) -> Result<(), WorkspaceCommandError> {
save_navigation_history(
caller,
&NavigationHistory {
version: 1,
active_by_client: state.active_by_client.clone(),
previous_by_client: state.previous_by_client.clone(),
},
)
}
fn load_catalog(caller: &impl HostRuntimeApi) -> Result<Vec<WorkspaceRecord>, String> {
let response = caller
.storage_get(&StorageGetRequest::new(bmux_plugin_sdk::storage_key!(
"workspaces.catalog"
)))
.map_err(|error| error.to_string())?;
response.value.map_or_else(
|| Ok(Vec::new()),
|value| serde_json::from_slice(&value).map_err(|error| error.to_string()),
)
}
fn persist_new_workspace(
caller: &impl HostRuntimeApi,
state: &mut WorkspaceState,
record: WorkspaceRecord,
) -> Result<(), WorkspaceCommandError> {
let mut proposed = state.records.clone();
proposed.push(record);
save_catalog(caller, &proposed)?;
state.records = proposed;
Ok(())
}
fn save_catalog(
caller: &impl HostRuntimeApi,
records: &[WorkspaceRecord],
) -> Result<(), WorkspaceCommandError> {
let value = serde_json::to_vec(records).map_err(|error| WorkspaceCommandError::Failed {
reason: error.to_string(),
})?;
caller
.storage_set(&StorageSetRequest::new(
bmux_plugin_sdk::storage_key!("workspaces.catalog"),
value,
))
.map_err(|error| WorkspaceCommandError::Failed {
reason: error.to_string(),
})
}
fn option_value(arguments: &[String], option: &str) -> Option<String> {
let long = format!("--{option}");
arguments.windows(2).find_map(|pair| {
(pair.first().map(String::as_str) == Some(long.as_str())).then(|| pair[1].clone())
})
}
fn positional_value_at(arguments: &[String], target_index: usize) -> Option<String> {
let mut positional_index = 0usize;
let mut index = 0usize;
while index < arguments.len() {
if arguments[index].starts_with('-') {
index = index.saturating_add(2);
continue;
}
if positional_index == target_index {
return Some(arguments[index].clone());
}
positional_index = positional_index.saturating_add(1);
index = index.saturating_add(1);
}
None
}
fn run_command(context: &NativeCommandContext) -> Result<(), String> {
match context.command.as_str() {
"list-workspaces" => {
for workspace in list_workspaces_for_client(context, context.caller_client_id)? {
println!("{}\t{}", workspace.id, workspace.name);
}
Ok(())
}
"new-workspace" => {
let ack = new_workspace_for_context(context, option_value(&context.arguments, "name"))?;
record_outcome(&ack);
Ok(())
}
"activate-tab" => {
let id = positional_value_at(&context.arguments, 0).ok_or("missing CONTEXT_ID")?;
let id = Uuid::parse_str(&id).map_err(|error| error.to_string())?;
let caller_id =
client_id(context.caller_client_id).map_err(|error| format!("{error:?}"))?;
let ack = activate_tab_for_client(context, id, caller_id)
.map_err(|error| format!("{error:?}"))?;
record_outcome(&ack);
Ok(())
}
"switch-workspace" => command_switch(context, &parse_selector_argument(context, 0)?),
"next-workspace" => command_cycle(context, 1),
"prev-workspace" => command_cycle(context, -1),
"last-workspace" => command_last(context),
"rename-workspace" => {
let selector = parse_selector_argument(context, 0)?;
let name = positional_value_at(&context.arguments, 1)
.ok_or_else(|| "missing NAME".to_string())?;
let ack =
rename_workspace(context, &selector, name).map_err(|error| format!("{error:?}"))?;
record_outcome(&ack);
Ok(())
}
"kill-workspace" => {
let selector = parse_selector_argument(context, 0)?;
let ack = kill_workspace(context, &selector).map_err(|error| format!("{error:?}"))?;
record_outcome(&ack);
Ok(())
}
"move-tab-to-workspace" => {
let context_id = positional_value_at(&context.arguments, 0)
.ok_or_else(|| "missing CONTEXT_ID".to_string())
.and_then(|value| Uuid::parse_str(&value).map_err(|error| error.to_string()))?;
let selector = parse_selector_argument(context, 1)?;
let ack =
move_tab(context, context_id, &selector).map_err(|error| format!("{error:?}"))?;
record_outcome(&ack);
Ok(())
}
command => Err(format!("unsupported command '{command}'")),
}
}
fn new_workspace_for_context(
context: &NativeCommandContext,
name: Option<String>,
) -> Result<WorkspaceAck, String> {
let client_id = context
.caller_client_id
.ok_or_else(|| "workspace operation requires caller client id".to_string())?;
new_workspace_for_client(context, name, client_id).map_err(|error| format!("{error:?}"))
}
fn command_switch(
context: &NativeCommandContext,
selector: &WorkspaceSelector,
) -> Result<(), String> {
let client_id = context
.caller_client_id
.ok_or_else(|| "workspace operation requires caller client id".to_string())?;
let ack = switch_workspace_for_client(context, selector, client_id)
.map_err(|error| format!("{error:?}"))?;
record_outcome(&ack);
Ok(())
}
fn command_cycle(context: &NativeCommandContext, offset: isize) -> Result<(), String> {
let client_id = context
.caller_client_id
.ok_or_else(|| "workspace operation requires caller client id".to_string())?;
let target = {
let state = state()?;
let guard = state
.read()
.map_err(|_| "workspace state lock poisoned".to_string())?;
let current = guard.active_id(client_id);
let index = guard
.records
.iter()
.position(|record| record.id == current)
.unwrap_or(0);
let len = guard.records.len();
if offset < 0 {
guard.records[index
.checked_sub(1)
.unwrap_or_else(|| len.saturating_sub(1))]
.id
} else {
guard.records[index.saturating_add(1) % len].id
}
};
command_switch(
context,
&WorkspaceSelector {
id: Some(target),
name: None,
},
)
}
fn command_last(context: &NativeCommandContext) -> Result<(), String> {
let client_id = context
.caller_client_id
.ok_or_else(|| "workspace operation requires caller client id".to_string())?;
let previous = state()?
.read()
.map_err(|_| "workspace state lock poisoned".to_string())?
.previous_by_client
.get(&client_id)
.copied()
.ok_or_else(|| "no previous workspace".to_string())?;
command_switch(
context,
&WorkspaceSelector {
id: Some(previous),
name: None,
},
)
}
fn parse_selector_argument(
context: &NativeCommandContext,
index: usize,
) -> Result<WorkspaceSelector, String> {
let target = positional_value_at(&context.arguments, index)
.ok_or_else(|| "missing TARGET".to_string())?;
Ok(Uuid::parse_str(&target).map_or_else(
|_| WorkspaceSelector {
id: None,
name: Some(target),
},
|id| WorkspaceSelector {
id: Some(id),
name: None,
},
))
}
fn record_outcome(ack: &WorkspaceAck) {
if let Some(context_id) = ack.selected_context_id {
bmux_plugin_sdk::record_command_outcome_metadata(
SELECTED_CONTEXT_OUTCOME_KEY,
serde_json::json!(context_id),
);
}
}
struct WorkspacesStateHandle {
caller: Arc<TypedServiceCaller>,
}
impl WorkspacesStateService for WorkspacesStateHandle {
fn list_workspaces<'a>(
&'a self,
) -> Pin<Box<dyn Future<Output = Vec<WorkspaceSummary>> + Send + 'a>> {
Box::pin(async move { list_workspaces(self.caller.as_ref()).unwrap_or_default() })
}
fn get_workspace<'a>(
&'a self,
selector: WorkspaceSelector,
) -> Pin<Box<dyn Future<Output = Result<WorkspaceSummary, WorkspaceQueryError>> + Send + 'a>>
{
Box::pin(async move { get_workspace(self.caller.as_ref(), &selector) })
}
fn current_workspace<'a>(
&'a self,
) -> Pin<Box<dyn Future<Output = Option<WorkspaceSummary>> + Send + 'a>> {
Box::pin(async move { current_workspace(self.caller.as_ref()).unwrap_or(None) })
}
}
struct WorkspacesCommandsHandle {
caller: Arc<TypedServiceCaller>,
}
impl WorkspacesActivationV1Service for WorkspacesCommandsHandle {
fn activate_tab<'a>(
&'a self,
context_id: Uuid,
) -> Pin<Box<dyn Future<Output = Result<WorkspaceAck, WorkspaceCommandError>> + Send + 'a>>
{
Box::pin(async move {
let client_id = client_id(resolve_client_id(self.caller.as_ref()))?;
activate_tab_for_client(self.caller.as_ref(), context_id, client_id)
})
}
}
impl WorkspacesCommandsService for WorkspacesCommandsHandle {
fn new_workspace<'a>(
&'a self,
name: Option<String>,
) -> Pin<Box<dyn Future<Output = Result<WorkspaceAck, WorkspaceCommandError>> + Send + 'a>>
{
Box::pin(async move { new_workspace(self.caller.as_ref(), name) })
}
fn rename_workspace<'a>(
&'a self,
selector: WorkspaceSelector,
name: String,
) -> Pin<Box<dyn Future<Output = Result<WorkspaceAck, WorkspaceCommandError>> + Send + 'a>>
{
Box::pin(async move { rename_workspace(self.caller.as_ref(), &selector, name) })
}
fn kill_workspace<'a>(
&'a self,
selector: WorkspaceSelector,
) -> Pin<Box<dyn Future<Output = Result<WorkspaceAck, WorkspaceCommandError>> + Send + 'a>>
{
Box::pin(async move { kill_workspace(self.caller.as_ref(), &selector) })
}
fn switch_workspace<'a>(
&'a self,
selector: WorkspaceSelector,
) -> Pin<Box<dyn Future<Output = Result<WorkspaceAck, WorkspaceCommandError>> + Send + 'a>>
{
Box::pin(async move { switch_workspace(self.caller.as_ref(), &selector) })
}
fn next_workspace<'a>(
&'a self,
) -> Pin<Box<dyn Future<Output = Result<WorkspaceAck, WorkspaceCommandError>> + Send + 'a>>
{
Box::pin(async move { cycle_workspace(self.caller.as_ref(), 1) })
}
fn prev_workspace<'a>(
&'a self,
) -> Pin<Box<dyn Future<Output = Result<WorkspaceAck, WorkspaceCommandError>> + Send + 'a>>
{
Box::pin(async move { cycle_workspace(self.caller.as_ref(), -1) })
}
fn last_workspace<'a>(
&'a self,
) -> Pin<Box<dyn Future<Output = Result<WorkspaceAck, WorkspaceCommandError>> + Send + 'a>>
{
Box::pin(async move { last_workspace(self.caller.as_ref()) })
}
fn move_tab_to_workspace<'a>(
&'a self,
context_id: Uuid,
workspace: WorkspaceSelector,
) -> Pin<Box<dyn Future<Output = Result<WorkspaceAck, WorkspaceCommandError>> + Send + 'a>>
{
Box::pin(async move { move_tab(self.caller.as_ref(), context_id, &workspace) })
}
}
bmux_plugin_sdk::export_plugin!(WorkspacesPlugin, include_str!("../plugin.toml"));
#[cfg(test)]
mod tests {
#[test]
fn catalog_write_failure_does_not_publish_new_workspace() {
struct FailingStorage;
impl bmux_plugin::ServiceCaller for FailingStorage {
fn execute_kernel_request(
&self,
_request: bmux_ipc::Request,
) -> bmux_plugin_sdk::Result<bmux_ipc::ResponsePayload> {
panic!("catalog persistence must not invoke kernel execution")
}
fn call_service_raw(
&self,
_capability: &str,
_kind: bmux_plugin_sdk::ServiceKind,
_interface: &str,
_operation: &str,
_payload: Vec<u8>,
) -> bmux_plugin_sdk::Result<Vec<u8>> {
Err(bmux_plugin_sdk::PluginError::ServiceProtocol {
details: "injected storage failure".into(),
})
}
}
let mut state = super::WorkspaceState::default();
state.ensure_default();
let result = super::persist_new_workspace(
&FailingStorage,
&mut state,
super::WorkspaceRecord {
id: uuid::Uuid::new_v4(),
name: "new".into(),
},
);
assert!(result.is_err());
assert_eq!(state.records.len(), 1);
assert!(state.records[0].id.is_nil());
assert!(state.active_by_client.is_empty());
}
#[test]
fn manifest_declares_tab_placement_capability() {
use bmux_plugin_sdk::TypedServiceEndpoint;
let manifest =
bmux_plugin::PluginManifest::from_toml_str(include_str!("../plugin.toml")).unwrap();
let capability =
bmux_tabs_plugin_api::tabs_placement_v1::client::PlaceTabEndpoint::CAPABILITY;
assert!(
manifest
.required_capabilities
.iter()
.any(|value| value.as_str() == capability.as_str())
);
}
use super::*;
use bmux_clients_plugin_api::clients_state::{ClientQueryError, ClientSummary};
use bmux_contexts_plugin_api::contexts_commands::{ContextAck, SetContextAttributesError};
use bmux_plugin::test_support::{TestServiceRouter, install_test_service_router};
use bmux_plugin_sdk::{
PluginError, ServiceKind, StorageGetResponse, decode_service_message,
encode_service_message,
};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
static TEST_STATE_LOCK: Mutex<()> = Mutex::new(());
struct MockHost {
current_client_id: Mutex<Uuid>,
contexts: Mutex<Vec<contexts_state::ContextSummary>>,
selected_contexts: Mutex<Vec<Uuid>>,
storage: Mutex<BTreeMap<String, Vec<u8>>>,
}
impl MockHost {
fn new(current_client_id: Uuid, contexts: Vec<contexts_state::ContextSummary>) -> Self {
Self {
current_client_id: Mutex::new(current_client_id),
contexts: Mutex::new(contexts),
selected_contexts: Mutex::new(Vec::new()),
storage: Mutex::new(BTreeMap::new()),
}
}
fn set_current_client(&self, client_id: Uuid) {
*self
.current_client_id
.lock()
.expect("current client lock should succeed") = client_id;
}
}
impl ServiceCaller for MockHost {
#[allow(clippy::too_many_lines)]
fn call_service_raw(
&self,
_capability: &str,
_kind: ServiceKind,
interface_id: &str,
operation: &str,
payload: Vec<u8>,
) -> bmux_plugin_sdk::Result<Vec<u8>> {
match (interface_id, operation) {
("clients-state", "current-client") => {
let id = *self
.current_client_id
.lock()
.expect("current client lock should succeed");
let result: Result<ClientSummary, ClientQueryError> = Ok(ClientSummary {
id,
selected_session_id: None,
selected_context_id: None,
following_client_id: None,
following_global: false,
});
encode_service_message(&result)
}
("contexts-state", "list-contexts") => encode_service_message(
&self
.contexts
.lock()
.expect("contexts lock should succeed")
.clone(),
),
("contexts-activation-v1", "activate") => {
let request: bmux_contexts_plugin_api::contexts_activation_v1::client::ActivateRequest = decode_service_message(&payload)?;
self.selected_contexts
.lock()
.unwrap()
.push(request.context_id);
let result: Result<
ContextAck,
bmux_contexts_plugin_api::contexts_commands::SelectContextError,
> = Ok(ContextAck {
id: request.context_id,
session_id: None,
});
encode_service_message(&result)
}
("contexts-commands", "select-context") => {
#[derive(Deserialize)]
struct Args {
selector: contexts_state::ContextSelector,
}
let request: Args = decode_service_message(&payload)?;
let id = request
.selector
.id
.ok_or_else(|| PluginError::ServiceProtocol {
details: "test select requires an id".to_string(),
})?;
self.selected_contexts
.lock()
.expect("selected contexts lock should succeed")
.push(id);
let result: Result<
ContextAck,
bmux_contexts_plugin_api::contexts_commands::SelectContextError,
> = Ok(ContextAck {
id,
session_id: None,
});
encode_service_message(&result)
}
("tabs-placement-v1", "place-tab") => {
let request: bmux_tabs_plugin_api::tabs_placement_v1::client::PlaceTabRequest =
decode_service_message(&payload)?;
let mut contexts = self.contexts.lock().unwrap();
let context = contexts
.iter_mut()
.find(|context| context.id == request.context_id)
.unwrap();
context
.attributes
.insert("workspace".into(), request.workspace_id.to_string());
drop(contexts);
encode_service_message(&Ok::<(), String>(()))
}
("contexts-commands", "set-context-attributes") => {
#[derive(Deserialize)]
struct Args {
selector: contexts_state::ContextSelector,
attributes: BTreeMap<String, String>,
}
let request: Args = decode_service_message(&payload)?;
let id = request
.selector
.id
.ok_or_else(|| PluginError::ServiceProtocol {
details: "test attribute update requires an id".to_string(),
})?;
let mut contexts = self.contexts.lock().expect("contexts lock should succeed");
let context = contexts
.iter_mut()
.find(|context| context.id == id)
.ok_or_else(|| PluginError::ServiceProtocol {
details: "test context not found".to_string(),
})?;
context.attributes = request.attributes;
drop(contexts);
let result: Result<ContextAck, SetContextAttributesError> = Ok(ContextAck {
id,
session_id: None,
});
encode_service_message(&result)
}
("storage-query/v1", "get") => {
let request: StorageGetRequest = decode_service_message(&payload)?;
let value = self
.storage
.lock()
.expect("storage lock should succeed")
.get(request.key.as_str())
.cloned();
encode_service_message(&StorageGetResponse { value })
}
("storage-command/v1", "set") => {
let request: StorageSetRequest = decode_service_message(&payload)?;
self.storage
.lock()
.expect("storage lock should succeed")
.insert(request.key.as_str().to_string(), request.value);
encode_service_message(&())
}
_ => Err(PluginError::UnsupportedHostOperation {
operation: "workspaces_test_host",
}),
}
}
fn execute_kernel_request(
&self,
_request: bmux_ipc::Request,
) -> bmux_plugin_sdk::Result<bmux_ipc::ResponsePayload> {
Err(PluginError::UnsupportedHostOperation {
operation: "workspaces_test_kernel",
})
}
}
#[allow(clippy::result_large_err)]
fn install_host_router(
host: Arc<MockHost>,
) -> bmux_plugin::test_support::TestServiceRouterGuard {
let router: TestServiceRouter = Arc::new(
move |_plugin, _client, _capability, kind, interface, operation, payload| {
host.call_service_raw("", kind, interface, operation, payload)
},
);
install_test_service_router(router)
}
fn context(id: u128, workspace_id: Uuid) -> contexts_state::ContextSummary {
contexts_state::ContextSummary {
id: Uuid::from_u128(id),
name: Some(format!("tab-{id}")),
attributes: BTreeMap::from([(
"workspace".to_string(),
workspace_attribute(workspace_id),
)]),
}
}
fn install_workspace_state(state: WorkspaceState) {
global_plugin_state_registry().register::<WorkspaceState>(&Arc::new(RwLock::new(state)));
}
#[test]
fn default_workspace_is_stable_and_selected() {
let client_id = Uuid::from_u128(1);
let mut state = WorkspaceState::default();
state.ensure_default();
assert_eq!(state.records.len(), 1);
assert_eq!(state.records[0].id, Uuid::nil());
assert_eq!(state.active_id(client_id), Uuid::nil());
}
#[test]
fn selecting_workspace_tracks_previous_per_client() {
let first_client = Uuid::from_u128(1);
let second_client = Uuid::from_u128(2);
let workspace_id = Uuid::from_u128(3);
let mut state = WorkspaceState::default();
state.ensure_default();
state.records.push(WorkspaceRecord {
id: workspace_id,
name: "second".to_string(),
});
state.select(first_client, workspace_id);
assert_eq!(state.active_id(first_client), workspace_id);
assert_eq!(
state.previous_by_client.get(&first_client),
Some(&Uuid::nil())
);
assert_eq!(state.active_id(second_client), Uuid::nil());
assert!(!state.previous_by_client.contains_key(&second_client));
}
#[test]
fn workspace_target_uses_first_context_from_mru_order() {
let workspace_id = Uuid::from_u128(2);
let first = contexts_state::ContextSummary {
id: Uuid::from_u128(10),
name: Some("recent".to_string()),
attributes: std::collections::BTreeMap::from([(
"workspace".to_string(),
workspace_id.to_string(),
)]),
};
let second = contexts_state::ContextSummary {
id: Uuid::from_u128(11),
name: Some("older".to_string()),
attributes: first.attributes.clone(),
};
assert_eq!(
most_recent_context_in_workspace(&[first.clone(), second], workspace_id),
Some(first.id)
);
}
#[test]
fn command_outcome_records_selected_context_for_attach() {
let context_id = Uuid::from_u128(42);
bmux_plugin_sdk::begin_command_outcome_capture();
record_outcome(&WorkspaceAck {
id: Uuid::from_u128(2),
selected_context_id: Some(context_id),
});
let outcome = bmux_plugin_sdk::finish_command_outcome_capture();
let expected = context_id.to_string();
assert_eq!(
outcome
.metadata
.get(SELECTED_CONTEXT_OUTCOME_KEY)
.and_then(serde_json::Value::as_str),
Some(expected.as_str())
);
}
#[test]
fn navigation_history_migration_preserves_legacy_and_rejects_unknown_versions() {
let host = MockHost::new(Uuid::from_u128(1), Vec::new());
let original =
serde_json::to_vec(&HashMap::from([(Uuid::from_u128(1), Uuid::from_u128(4))])).unwrap();
host.storage
.lock()
.unwrap()
.insert(ACTIVE_BY_CLIENT_KEY.into(), original.clone());
let history = load_navigation_history(&host).unwrap();
assert_eq!(
history.active_by_client.get(&Uuid::from_u128(1)),
Some(&Uuid::from_u128(4))
);
assert_eq!(host.storage.lock().unwrap()[ACTIVE_BY_CLIENT_KEY], original);
host.storage
.lock()
.unwrap()
.insert(ACTIVE_BY_CLIENT_KEY.into(), b"broken".to_vec());
assert!(load_navigation_history(&host).is_ok());
let mut future = history;
future.version = 2;
host.storage
.lock()
.unwrap()
.insert(HISTORY_KEY.into(), serde_json::to_vec(&future).unwrap());
assert!(
load_navigation_history(&host)
.unwrap_err()
.contains("unsupported")
);
host.storage.lock().unwrap().remove(HISTORY_KEY);
assert!(load_navigation_history(&host).is_err());
}
#[test]
fn effective_workspace_follows_selected_context_not_saved_history() {
let other = Uuid::from_u128(4);
let target = context(100, other);
assert_eq!(
effective_workspace(Some(target.id), std::slice::from_ref(&target), Uuid::nil())
.unwrap(),
other
);
assert!(effective_workspace(Some(target.id), &[], Uuid::nil()).is_err());
let moved = context(100, Uuid::nil());
assert_eq!(
effective_workspace(Some(target.id), &[moved], other).unwrap(),
Uuid::nil()
);
assert_eq!(effective_workspace(None, &[], other).unwrap(), other);
}
#[test]
fn client_selection_maps_round_trip_through_storage() {
let client_id = Uuid::from_u128(1);
let workspace_id = Uuid::from_u128(2);
let values = HashMap::from([(client_id, workspace_id)]);
let encoded = serde_json::to_vec(&values).expect("selection map should encode");
let decoded: HashMap<Uuid, Uuid> =
serde_json::from_slice(&encoded).expect("selection map should decode");
assert_eq!(decoded, values);
}
#[test]
fn removing_workspace_repairs_all_client_pointers() {
let removed = Uuid::from_u128(10);
let fallback = Uuid::from_u128(20);
let first_client = Uuid::from_u128(1);
let second_client = Uuid::from_u128(2);
let mut state = WorkspaceState {
records: vec![
WorkspaceRecord {
id: removed,
name: "removed".to_string(),
},
WorkspaceRecord {
id: fallback,
name: "fallback".to_string(),
},
],
active_by_client: HashMap::from([(first_client, removed), (second_client, fallback)]),
previous_by_client: HashMap::from([(first_client, fallback), (second_client, removed)]),
selected_context_by_client_workspace: HashMap::new(),
};
let actual_fallback = state.remove(removed);
assert_eq!(actual_fallback, fallback);
assert!(state.active_by_client.values().all(|id| *id == fallback));
assert!(state.previous_by_client.values().all(|id| *id == fallback));
}
#[test]
fn removing_only_workspace_creates_replacement_and_repairs_clients() {
let removed = Uuid::from_u128(10);
let client = Uuid::from_u128(1);
let mut state = WorkspaceState {
records: vec![WorkspaceRecord {
id: removed,
name: "only".to_string(),
}],
active_by_client: HashMap::from([(client, removed)]),
previous_by_client: HashMap::from([(client, removed)]),
selected_context_by_client_workspace: HashMap::new(),
};
let replacement = state.remove(removed);
assert_ne!(replacement, removed);
assert_eq!(state.records[0].id, replacement);
assert_eq!(state.records[0].name, DEFAULT_WORKSPACE_NAME);
assert_eq!(state.records.len(), 1);
assert_eq!(state.active_id(client), replacement);
assert_eq!(state.previous_by_client.get(&client), Some(&replacement));
}
#[test]
fn exact_activation_resolves_membership_without_fallback() {
let _test_guard = TEST_STATE_LOCK.lock().unwrap();
let client = Uuid::from_u128(1);
let other = Uuid::from_u128(4);
let target = context(102, other);
let host = Arc::new(MockHost::new(
client,
vec![
context(101, Uuid::nil()),
target.clone(),
context(103, other),
],
));
let _router = install_host_router(host.clone());
install_workspace_state(WorkspaceState {
records: vec![
WorkspaceRecord {
id: Uuid::nil(),
name: "default".into(),
},
WorkspaceRecord {
id: other,
name: "workspace-4".into(),
},
],
selected_context_by_client_workspace: HashMap::from([(
(client, other),
Uuid::from_u128(103),
)]),
..WorkspaceState::default()
});
let ack = activate_tab_for_client(host.as_ref(), target.id, client).unwrap();
assert_eq!(ack.id, other);
assert_eq!(ack.selected_context_id, Some(target.id));
assert_eq!(*host.selected_contexts.lock().unwrap(), vec![target.id]);
host.contexts
.lock()
.unwrap()
.retain(|context| context.id != target.id);
assert_eq!(
activate_tab_for_client(host.as_ref(), target.id, client),
Err(WorkspaceCommandError::NotFound)
);
assert_eq!(*host.selected_contexts.lock().unwrap(), vec![target.id]);
}
#[test]
fn service_switches_remain_isolated_per_client() {
let _test_guard = TEST_STATE_LOCK
.lock()
.expect("workspace test state lock should succeed");
let first_client = Uuid::from_u128(1);
let second_client = Uuid::from_u128(2);
let first_workspace = Uuid::nil();
let second_workspace = Uuid::from_u128(20);
let first_context = context(101, first_workspace);
let second_context = context(102, second_workspace);
let host = Arc::new(MockHost::new(
first_client,
vec![first_context.clone(), second_context.clone()],
));
let _router = install_host_router(Arc::clone(&host));
install_workspace_state(WorkspaceState {
records: vec![
WorkspaceRecord {
id: first_workspace,
name: DEFAULT_WORKSPACE_NAME.to_string(),
},
WorkspaceRecord {
id: second_workspace,
name: "second".to_string(),
},
],
active_by_client: HashMap::new(),
previous_by_client: HashMap::new(),
selected_context_by_client_workspace: HashMap::new(),
});
let first_ack = switch_workspace(
host.as_ref(),
&WorkspaceSelector {
id: Some(second_workspace),
name: None,
},
)
.expect("first client should switch");
host.set_current_client(second_client);
let second_ack = switch_workspace(
host.as_ref(),
&WorkspaceSelector {
id: Some(first_workspace),
name: None,
},
)
.expect("second client should switch");
assert_eq!(first_ack.selected_context_id, Some(second_context.id));
assert_eq!(second_ack.selected_context_id, Some(first_context.id));
let state = state().expect("workspace state should exist");
let guard = state.read().expect("workspace state lock should succeed");
assert_eq!(guard.active_id(first_client), second_workspace);
assert_eq!(guard.active_id(second_client), first_workspace);
assert_eq!(
guard.previous_by_client.get(&first_client),
Some(&first_workspace)
);
assert!(!guard.previous_by_client.contains_key(&second_client));
drop(guard);
let selected_contexts = host
.selected_contexts
.lock()
.expect("selected contexts lock should succeed")
.clone();
assert_eq!(selected_contexts, vec![second_context.id, first_context.id]);
}
#[test]
fn deleting_viewed_workspace_repairs_every_client_and_moves_tabs() {
let _test_guard = TEST_STATE_LOCK
.lock()
.expect("workspace test state lock should succeed");
let deleting_client = Uuid::from_u128(1);
let viewing_client = Uuid::from_u128(2);
let removed_workspace = Uuid::from_u128(20);
let fallback_workspace = Uuid::nil();
let removed_context = context(101, removed_workspace);
let fallback_context = context(102, fallback_workspace);
let host = Arc::new(MockHost::new(
deleting_client,
vec![removed_context, fallback_context],
));
let _router = install_host_router(Arc::clone(&host));
global_event_bus().register_channel::<WorkspaceEvent>(workspaces_events::EVENT_KIND);
let mut events = global_event_bus()
.subscribe::<WorkspaceEvent>(&workspaces_events::EVENT_KIND)
.expect("workspace event subscription should succeed");
install_workspace_state(WorkspaceState {
records: vec![
WorkspaceRecord {
id: fallback_workspace,
name: DEFAULT_WORKSPACE_NAME.to_string(),
},
WorkspaceRecord {
id: removed_workspace,
name: "removed".to_string(),
},
],
active_by_client: HashMap::from([
(deleting_client, fallback_workspace),
(viewing_client, removed_workspace),
]),
previous_by_client: HashMap::from([
(deleting_client, removed_workspace),
(viewing_client, fallback_workspace),
]),
selected_context_by_client_workspace: HashMap::new(),
});
let ack = kill_workspace(
host.as_ref(),
&WorkspaceSelector {
id: Some(removed_workspace),
name: None,
},
)
.expect("workspace deletion should succeed");
assert_eq!(ack.id, removed_workspace);
let event = events
.try_recv()
.expect("workspace removal event should be emitted");
assert_eq!(
event.as_ref(),
&WorkspaceEvent::Removed {
workspace_id: removed_workspace,
}
);
let state = state().expect("workspace state should exist");
let guard = state.read().expect("workspace state lock should succeed");
assert_eq!(guard.active_id(deleting_client), fallback_workspace);
assert_eq!(guard.active_id(viewing_client), fallback_workspace);
assert_eq!(
guard.previous_by_client.get(&deleting_client),
Some(&fallback_workspace)
);
assert_eq!(
guard.previous_by_client.get(&viewing_client),
Some(&fallback_workspace)
);
drop(guard);
let contexts = host.contexts.lock().expect("contexts lock should succeed");
assert_eq!(context_workspace_id(&contexts[0]), fallback_workspace);
drop(contexts);
let storage = host.storage.lock().expect("storage lock should succeed");
let history: NavigationHistory = serde_json::from_slice(
storage
.get(HISTORY_KEY)
.expect("navigation history should persist"),
)
.expect("navigation history should decode");
drop(storage);
assert_eq!(
history.active_by_client.get(&viewing_client),
Some(&fallback_workspace)
);
}
#[test]
fn workspace_attribute_maps_default_and_uuid_values() {
assert_eq!(workspace_attribute(Uuid::nil()), "default");
let id = Uuid::from_u128(42);
assert_eq!(workspace_attribute(id), id.to_string());
}
}