feat: add reversible workspace panels
CI / rust (push) Successful in 2m11s

This commit is contained in:
2026-08-31 18:18:20 -07:00
parent 63c9cb6210
commit abcf664ab8
12 changed files with 652 additions and 90 deletions
+170 -2
View File
@@ -268,6 +268,13 @@ pub enum WorkspaceCommand {
ratio: SplitRatio,
placement: PanePlacement,
},
AttachPane {
target: PaneId,
pane: PaneId,
axis: SplitAxis,
ratio: SplitRatio,
placement: PanePlacement,
},
SelectPane {
pane: PaneId,
},
@@ -291,6 +298,7 @@ impl WorkspaceCommand {
} => WorkspaceCapability::Execute,
Self::SplitPane { pane, .. } if pane.launch.is_some() => WorkspaceCapability::Execute,
Self::SplitPane { .. }
| Self::AttachPane { .. }
| Self::SelectPane { .. }
| Self::RenamePane { .. }
| Self::ClosePane {
@@ -319,6 +327,10 @@ pub enum WorkspaceEvent {
PaneSelected {
pane: PaneId,
},
PaneAttached {
target: PaneId,
pane: PaneId,
},
PaneRenamed {
pane: PaneId,
},
@@ -366,6 +378,7 @@ pub struct WorkspaceState {
id: WorkspaceId,
name: String,
panes: BTreeMap<PaneId, PaneDefinition>,
detached_panes: BTreeMap<PaneId, PaneDefinition>,
layout: LayoutNode,
selected: PaneId,
applied_requests: BTreeMap<CommandRequestId, WorkspaceRequest>,
@@ -396,6 +409,7 @@ impl WorkspaceState {
id,
name,
panes,
detached_panes: BTreeMap::new(),
layout,
selected,
applied_requests: BTreeMap::new(),
@@ -433,11 +447,21 @@ impl WorkspaceState {
self.panes.get(id)
}
#[must_use]
pub fn detached_pane(&self, id: &PaneId) -> Option<&PaneDefinition> {
self.detached_panes.get(id)
}
#[must_use]
pub fn pane_count(&self) -> usize {
self.panes.len()
}
#[must_use]
pub fn detached_pane_count(&self) -> usize {
self.detached_panes.len()
}
/// Applies one idempotent command after checking its required capability.
///
/// # Errors
@@ -511,7 +535,7 @@ impl WorkspaceState {
if !self.layout.contains(&target) {
return Err(WorkspaceError::PaneNotFound(target));
}
if self.panes.contains_key(&pane.id) {
if self.panes.contains_key(&pane.id) || self.detached_panes.contains_key(&pane.id) {
return Err(WorkspaceError::PaneAlreadyExists(pane.id));
}
pane.validate()?;
@@ -527,6 +551,31 @@ impl WorkspaceState {
pane: pane_id,
})
}
WorkspaceCommand::AttachPane {
target,
pane,
axis,
ratio,
placement,
} => {
if !self.layout.contains(&target) {
return Err(WorkspaceError::PaneNotFound(target));
}
if self.panes.contains_key(&pane) {
return Err(WorkspaceError::PaneAlreadyExists(pane));
}
let definition = self
.detached_panes
.remove(&pane)
.ok_or_else(|| WorkspaceError::PaneNotFound(pane.clone()))?;
let did_split = self
.layout
.split(&target, pane.clone(), axis, ratio, placement);
debug_assert!(did_split, "target presence was checked");
self.panes.insert(pane.clone(), definition);
self.selected = pane.clone();
Ok(WorkspaceEvent::PaneAttached { target, pane })
}
WorkspaceCommand::SelectPane { pane } => {
if !self.panes.contains_key(&pane) {
return Err(WorkspaceError::PaneNotFound(pane));
@@ -546,6 +595,11 @@ impl WorkspaceState {
Ok(WorkspaceEvent::PaneRenamed { pane })
}
WorkspaceCommand::ClosePane { pane, disposition } => {
if disposition == PaneCloseDisposition::Terminate
&& self.detached_panes.remove(&pane).is_some()
{
return Ok(WorkspaceEvent::PaneClosed { pane, disposition });
}
if self.panes.len() == 1 && self.panes.contains_key(&pane) {
return Err(WorkspaceError::CannotCloseLastPane);
}
@@ -557,7 +611,10 @@ impl WorkspaceState {
.clone()
.remove(&pane)
.ok_or(WorkspaceError::CannotCloseLastPane)?;
self.panes.remove(&pane);
let definition = self.panes.remove(&pane).expect("pane presence was checked");
if disposition == PaneCloseDisposition::Detach {
self.detached_panes.insert(pane.clone(), definition);
}
self.layout = layout;
if self.selected == pane {
self.selected = self.layout.first_pane().clone();
@@ -580,6 +637,11 @@ impl fmt::Debug for WorkspaceEvent {
.debug_struct("PaneSelected")
.field("pane", pane)
.finish(),
Self::PaneAttached { target, pane } => formatter
.debug_struct("PaneAttached")
.field("target", target)
.field("pane", pane)
.finish(),
Self::PaneRenamed { pane } => formatter
.debug_struct("PaneRenamed")
.field("pane", pane)
@@ -828,6 +890,8 @@ mod tests {
.unwrap();
assert_eq!(state.selected().as_str(), "root");
assert_eq!(state.pane_count(), 1);
assert_eq!(state.detached_pane_count(), 1);
assert!(state.detached_pane(&id(PaneId::new, "second")).is_some());
assert!(matches!(
state.apply(
request(
@@ -843,6 +907,110 @@ mod tests {
));
}
#[test]
fn detached_pane_can_be_reattached_without_a_new_launch() {
let mut state = workspace();
state
.apply(
request(
"split",
WorkspaceCommand::SplitPane {
target: id(PaneId::new, "root"),
pane: pane("agent", true),
axis: SplitAxis::Horizontal,
ratio: SplitRatio::default(),
placement: PanePlacement::After,
},
),
WorkspaceCapability::Execute,
)
.unwrap();
state
.apply(
request(
"detach",
WorkspaceCommand::ClosePane {
pane: id(PaneId::new, "agent"),
disposition: PaneCloseDisposition::Detach,
},
),
WorkspaceCapability::Configure,
)
.unwrap();
assert_eq!(state.pane_count(), 1);
assert_eq!(state.detached_pane_count(), 1);
state
.apply(
request(
"attach",
WorkspaceCommand::AttachPane {
target: id(PaneId::new, "root"),
pane: id(PaneId::new, "agent"),
axis: SplitAxis::Horizontal,
ratio: SplitRatio::default(),
placement: PanePlacement::After,
},
),
WorkspaceCapability::Configure,
)
.unwrap();
assert_eq!(state.pane_count(), 2);
assert_eq!(state.detached_pane_count(), 0);
assert_eq!(state.selected().as_str(), "agent");
assert!(state.pane(&id(PaneId::new, "agent")).is_some());
}
#[test]
fn terminating_a_detached_pane_requires_execute_and_removes_it() {
let mut state = workspace();
state
.apply(
request(
"split",
WorkspaceCommand::SplitPane {
target: id(PaneId::new, "root"),
pane: pane("agent", false),
axis: SplitAxis::Horizontal,
ratio: SplitRatio::default(),
placement: PanePlacement::After,
},
),
WorkspaceCapability::Configure,
)
.unwrap();
state
.apply(
request(
"detach",
WorkspaceCommand::ClosePane {
pane: id(PaneId::new, "agent"),
disposition: PaneCloseDisposition::Detach,
},
),
WorkspaceCapability::Configure,
)
.unwrap();
let terminate = request(
"terminate",
WorkspaceCommand::ClosePane {
pane: id(PaneId::new, "agent"),
disposition: PaneCloseDisposition::Terminate,
},
);
assert!(matches!(
state.apply(terminate.clone(), WorkspaceCapability::Configure),
Err(WorkspaceError::CapabilityDenied { .. })
));
state
.apply(terminate, WorkspaceCapability::Execute)
.unwrap();
assert_eq!(state.detached_pane_count(), 0);
assert_eq!(state.pane_count(), 1);
}
#[test]
fn rejects_invalid_identifiers_ratios_and_launch_surfaces() {
assert!(PaneId::new(" ").is_err());