use crate::ecs::components::{
Creation, CreationKind, Open, Pane, PaneState, Selection, Tab, TabOf, Viewer, Workspace,
};
use crate::ecs::messages::{Effect, Inbound, Requester};
use crate::ecs::resources::{Clock, Ids, Limits};
use crate::ecs::support::{
Failure, clear_barriers, default_command, despawn_pane, despawn_tab, despawn_workspace, effect,
event, focus_in_tab, is_not_retiring, is_pending, mark_tab_dirty, mark_workspace_dirty,
member_tabs, pane_entity, panes_in_workspace, reply, reply_all, tab_area, tab_id,
tab_workspace, terminate_pane, viewer_entity,
};
use crate::ecs::systems::lifecycle::TERMINATE_GRACE_MS;
use crate::ecs::systems::requests::switch_viewer_workspace;
use crate::ids::{PaneId, TabId};
use crate::layout::{LayoutTree, Rect};
use crate::proto::control::{CommandResult, ErrorCode, Event, Reply, RequestId};
use crate::terminal::ServerTerminal;
use bevy_ecs::prelude::*;
use std::path::PathBuf;
pub struct NewPane {
pub fixed_workspace: bool,
pub right_click: crate::view::RightClickPolicy,
pub argv: Vec<String>,
pub cwd: Option<PathBuf>,
pub env: Vec<(String, String)>,
pub requester: Requester,
pub request_id: RequestId,
pub final_retain_ms: u64,
}
pub fn reserve_pane(
world: &mut World,
workspace: Entity,
mut new: NewPane,
kind: CreationKind,
size: (u16, u16),
) -> Result<Entity, Failure> {
let limits = *world.resource::<Limits>();
if new.final_retain_ms == 0 {
return Err(Failure::invalid("final_retain_ms must be nonzero"));
}
let final_retain_ms = new
.final_retain_ms
.min(crate::proto::control::MAX_FINAL_RETENTION_MS);
if panes_in_workspace(world, workspace).len() >= limits.max_panes {
return Err(Failure::limit("configured pane limit reached"));
}
let env = std::mem::take(&mut new.env);
let argv = if new.argv.is_empty() {
default_command(world)
} else {
std::mem::take(&mut new.argv)
};
let id = world
.resource_mut::<Ids>()
.next_pane()
.ok_or_else(|| Failure::limit("pane identifiers exhausted"))?;
let tab = match &kind {
CreationKind::Split { tab, .. }
| CreationKind::NewTab { tab }
| CreationKind::Workspace { tab } => *tab,
};
let (rows, cols) = crate::terminal::clamp_dims(size.0, size.1);
let cwd = new
.cwd
.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")));
let workspace_name = world
.get::<Workspace>(workspace)
.map(|workspace| workspace.name.clone())
.ok_or_else(|| Failure::not_found("workspace disappeared"))?;
let workspace_stream = world
.get::<crate::ecs::events::EventLog>(workspace)
.map(crate::ecs::events::EventLog::cursor)
.ok_or_else(|| Failure::new(ErrorCode::Internal, "workspace stream missing"))?
.stream;
let entity = world
.spawn((
Pane {
routing_workspace: workspace,
workspace_pin: if new.fixed_workspace {
crate::ecs::components::WorkspacePin::Creation
} else {
crate::ecs::components::WorkspacePin::None
},
workspace_name,
workspace_stream,
id,
tab,
argv: argv.clone(),
cwd,
state: PaneState::Starting,
terminal: ServerTerminal::new(rows, cols, limits.scrollback_lines),
rect: Rect {
x: 0,
y: 0,
width: cols.saturating_add(2),
height: rows.saturating_add(2),
},
dirty: true,
event_pending: false,
last_event_seq: 0,
published_title: String::new(),
label: None,
right_click: new.right_click,
input_sequence: 0,
last_output_event_ms: None,
final_retain_ms,
},
Creation {
requesters: vec![(new.requester, new.request_id)],
kind,
},
))
.id();
world.resource_mut::<Ids>().panes.insert(id, entity);
if let Requester::Viewer(viewer) = new.requester
&& let Some(viewer) = viewer_entity(world, viewer)
&& let Some(mut viewer) = world.get_mut::<Viewer>(viewer)
{
viewer.barrier = Some(entity);
}
effect(
world,
Effect::SpawnPane {
pane: id,
argv,
cwd: new.cwd,
env,
rows,
cols,
},
);
Ok(entity)
}
pub fn reserve_tab(
world: &mut World,
workspace: Entity,
label: Option<String>,
) -> Result<Entity, Failure> {
let ids = world.resource_mut::<Ids>().next_tab();
let Some(id) = ids else {
return Err(Failure::limit("tab identifiers exhausted"));
};
let label = label.unwrap_or_else(|| {
world
.get_mut::<Workspace>(workspace)
.map(|mut workspace| {
workspace.tab_counter = workspace.tab_counter.saturating_add(1);
if workspace.tab_counter == 1 {
"main".to_owned()
} else {
format!("tab-{}", workspace.tab_counter)
}
})
.unwrap_or_default()
});
let entity = world
.spawn(Tab {
id,
workspace,
label,
layout: LayoutTree::new(Entity::PLACEHOLDER),
geometry: Vec::new(),
area: tab_area(24, 80),
layout_changed: true,
layout_generation: 0,
zoomed: None,
})
.id();
world.resource_mut::<Ids>().tabs.insert(id, entity);
Ok(entity)
}
pub fn reserve_workspace(
world: &mut World,
name: String,
requester: Requester,
request_id: RequestId,
) -> Result<Entity, Failure> {
let limits = *world.resource::<Limits>();
let workspace = reserve_empty_workspace(world, name)?;
let tab = match reserve_tab(world, workspace, None) {
Ok(tab) => tab,
Err(reply) => {
despawn_workspace(world, workspace);
return Err(reply);
}
};
match reserve_pane(
world,
workspace,
NewPane {
argv: Vec::new(),
cwd: None,
env: Vec::new(),
requester,
request_id,
final_retain_ms: limits.final_retain_ms,
fixed_workspace: false,
right_click: Default::default(),
},
CreationKind::Workspace { tab },
(crate::terminal::MIN_DIM.max(22), 80),
) {
Ok(_) => Ok(workspace),
Err(reply) => {
despawn_tab(world, tab);
despawn_workspace(world, workspace);
Err(reply)
}
}
}
pub fn apply_spawn_completions(world: &mut World) {
world.resource_scope::<Messages<Inbound>, _>(|world, inbound| {
for message in inbound.iter_current_update_messages() {
let Inbound::SpawnCompleted { pane: id, result } = message else {
continue;
};
let id = *id;
let Some(entity) = pane_entity(world, id) else {
if result.is_ok() {
effect(
world,
Effect::Terminate {
pane: id,
grace_ms: TERMINATE_GRACE_MS,
},
);
effect(world, Effect::ReleasePane { pane: id });
}
continue;
};
let Some(creation) = world.entity_mut(entity).take::<Creation>() else {
continue;
};
clear_barriers(world, entity);
match result {
Ok(pid) => complete(world, entity, id, *pid, creation),
Err(message) => roll_back(world, entity, creation, message),
}
}
});
}
fn complete(world: &mut World, entity: Entity, id: PaneId, pid: u32, creation: Creation) {
let Creation { requesters, kind } = creation;
let (tab, missing) = match kind {
CreationKind::Split { tab, .. } => (tab, "tab closed before the pane started"),
CreationKind::NewTab { tab } => (tab, "tab was discarded"),
CreationKind::Workspace { tab } => (tab, "workspace was discarded"),
};
let Some(workspace) = tab_workspace(world, tab) else {
return abandon(world, entity, pid, &requesters, missing);
};
match kind {
CreationKind::Split {
target,
axis,
ratio,
focus,
..
} => {
let mut target = Some(target).filter(|target| {
world
.get::<Tab>(tab)
.is_some_and(|tab| tab.layout.contains(*target))
});
if target.is_none() {
let selection = requesters
.first()
.and_then(|(requester, _)| match requester {
Requester::Viewer(viewer) => viewer_entity(world, *viewer)
.and_then(|viewer| world.get::<Viewer>(viewer))
.map(|viewer| viewer.selection.clone()),
_ => None,
})
.or_else(|| {
world
.get::<Workspace>(workspace)
.map(|w| w.selection.clone())
});
target = selection.and_then(|selection| focus_in_tab(world, &selection, tab));
}
let inserted = target.is_some_and(|target| {
world.get_mut::<Tab>(tab).is_some_and(|mut component| {
let ok = component.layout.split(target, entity, axis, ratio).is_ok();
component.layout_changed |= ok;
ok
})
});
if !inserted {
return abandon(
world,
entity,
pid,
&requesters,
"the pane to split is no longer available",
);
}
go_live(world, entity, pid);
if focus {
if let Some(mut component) = world.get_mut::<Tab>(tab) {
component.zoomed = None;
}
focus_requesters(world, &requesters, workspace, tab, entity);
} else {
mark_tab_dirty(world, tab);
}
announce_pane(world, workspace, tab, entity, id);
reply_all(world, requesters, |request| Reply::Completed {
id: request,
result: CommandResult::Pane { pane: id },
});
}
CreationKind::NewTab { .. } => {
let open = is_not_retiring(world, workspace);
let tab_limit = world.resource::<Limits>().max_tabs;
if !open || member_tabs(world, workspace).len() >= tab_limit {
let reason = if open {
"configured tab limit reached"
} else {
"workspace is closing"
};
despawn_tab(world, tab);
return abandon(world, entity, pid, &requesters, reason);
}
place_first_pane(world, tab, entity, pid);
let tab_id = tab_id(world, tab).unwrap_or_default();
let label = world
.get::<Tab>(tab)
.map(|tab| tab.label.clone())
.unwrap_or_default();
world.entity_mut(tab).insert(TabOf(workspace));
if let Some(mut component) = world.get_mut::<Workspace>(workspace) {
component.selection.tab = Some(tab);
}
focus_requesters(world, &requesters, workspace, tab, entity);
event(
world,
workspace,
Event::TabOpened {
id: 0,
tab: tab_id,
name: label,
},
);
announce_pane(world, workspace, tab, entity, id);
mark_workspace_dirty(world, workspace);
reply_all(world, requesters, |request| Reply::Completed {
id: request,
result: CommandResult::Tab { tab: tab_id },
});
}
CreationKind::Workspace { .. } => {
if !is_not_retiring(world, workspace) {
return abandon(
world,
entity,
pid,
&requesters,
"workspace was killed before it opened",
);
}
place_first_pane(world, tab, entity, pid);
world.entity_mut(tab).insert(TabOf(workspace));
let name = world
.get_mut::<Workspace>(workspace)
.map(|mut component| {
component.selection.select(tab, Some(entity));
component.name.clone()
})
.unwrap_or_default();
world.entity_mut(workspace).insert(Open);
let stream = world
.get::<crate::ecs::events::EventLog>(workspace)
.map_or(0, |log| log.cursor().stream);
effect(
world,
Effect::WorkspaceOpened {
name: name.clone(),
stream,
},
);
announce_pane(world, workspace, tab, entity, id);
for (requester, request_id) in requesters {
if let Requester::Viewer(viewer) = requester
&& let Some(viewer) = viewer_entity(world, viewer)
{
switch_viewer_workspace(world, viewer, workspace);
}
reply(
world,
requester,
Reply::Completed {
id: request_id,
result: CommandResult::Workspace { name: name.clone() },
},
);
}
}
}
}
fn place_first_pane(world: &mut World, tab: Entity, pane: Entity, pid: u32) {
if let Some(mut component) = world.get_mut::<Tab>(tab) {
component.layout = LayoutTree::new(pane);
component.layout_changed = true;
component.layout_generation = component.layout_generation.saturating_add(1);
}
go_live(world, pane, pid);
}
fn go_live(world: &mut World, entity: Entity, pid: u32) {
if let Some(mut pane) = world.get_mut::<Pane>(entity) {
if matches!(pane.state, PaneState::Starting) {
pane.state = PaneState::Live { pid };
}
pane.dirty = true;
}
}
fn focus_requesters(
world: &mut World,
requesters: &[(Requester, RequestId)],
workspace: Entity,
tab: Entity,
pane: Entity,
) {
for (requester, _) in requesters {
if let Requester::Viewer(viewer) = requester
&& let Some(viewer) = viewer_entity(world, *viewer)
&& let Some(mut viewer) = world.get_mut::<Viewer>(viewer)
&& viewer.workspace == workspace
{
viewer.selection.select(tab, Some(pane));
viewer.dirty = true;
}
}
if let Some(mut component) = world.get_mut::<Workspace>(workspace) {
component.selection.select(tab, Some(pane));
}
mark_tab_dirty(world, tab);
}
fn announce_pane(world: &mut World, workspace: Entity, tab: Entity, pane: Entity, id: PaneId) {
let command = world
.get::<Pane>(pane)
.map(|pane| pane.argv.clone())
.unwrap_or_default();
let tab_id: TabId = tab_id(world, tab).unwrap_or_default();
event(
world,
workspace,
Event::PaneOpened {
id: 0,
pane: id,
tab: tab_id,
command,
},
);
}
fn abandon(
world: &mut World,
entity: Entity,
pid: u32,
requesters: &[(Requester, RequestId)],
reason: &str,
) {
if let Some(mut pane) = world.get_mut::<Pane>(entity)
&& matches!(pane.state, PaneState::Starting)
{
pane.state = PaneState::Live { pid };
}
let now = world.resource::<Clock>().now_ms;
terminate_pane(world, entity, now, TERMINATE_GRACE_MS);
reply_all(world, requesters.iter().copied(), |id| {
Failure::conflict(reason).reply(id)
});
}
fn roll_back(world: &mut World, entity: Entity, creation: Creation, message: &str) {
match creation.kind {
CreationKind::Split { .. } => {}
CreationKind::NewTab { tab } => despawn_tab(world, tab),
CreationKind::Workspace { tab } => {
if let Some(workspace) = tab_workspace(world, tab) {
despawn_workspace(world, workspace);
}
despawn_tab(world, tab);
}
}
despawn_pane(world, entity);
reply_all(world, creation.requesters, |id| {
Failure::new(
ErrorCode::Internal,
format!("could not start the pane: {message}"),
)
.reply(id)
});
}
pub fn join_pending_workspace(
world: &mut World,
workspace: Entity,
requester: Requester,
request_id: RequestId,
) -> bool {
let panes: Vec<Entity> = world
.query::<(Entity, &Creation)>()
.iter(world)
.filter(|(_, creation)| matches!(creation.kind, CreationKind::Workspace { tab } if world.get::<Tab>(tab).is_some_and(|tab| tab.workspace == workspace)))
.map(|(entity, _)| entity)
.collect();
let Some(pane) = panes.first().copied() else {
return false;
};
if let Some(mut creation) = world.get_mut::<Creation>(pane) {
creation.requesters.push((requester, request_id));
return true;
}
false
}
pub fn workspace_pending(world: &World, workspace: Entity) -> bool {
is_pending(world, workspace)
}
pub fn reserve_empty_workspace(world: &mut World, name: String) -> Result<Entity, Failure> {
crate::ids::validate_workspace_name(&name)
.map_err(|error| Failure::invalid(error.to_string()))?;
let limits = *world.resource::<Limits>();
if world.resource::<Ids>().workspaces.len() >= limits.max_workspaces {
return Err(Failure::limit("configured workspace limit reached"));
}
if world.resource::<Ids>().workspace(&name).is_some() {
return Err(Failure::conflict(format!(
"workspace {name} already exists"
)));
}
let stream = world
.resource_mut::<Ids>()
.next_stream()
.ok_or_else(|| Failure::limit("workspace stream IDs exhausted"))?;
let step = world.resource::<Clock>().step;
let workspace = world
.spawn(Workspace {
name: name.clone(),
label: None,
selection: Selection::default(),
last_attached: step,
tab_counter: 0,
})
.id();
world
.entity_mut(workspace)
.insert(crate::ecs::events::EventLog::new(stream));
world
.resource_mut::<Ids>()
.workspaces
.insert(name.clone(), workspace);
Ok(workspace)
}