use std::collections::BTreeSet; use lumbridge_spike_model::PaneId as FixturePaneId; use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] pub(crate) struct PanelId(u64); impl PanelId { pub(crate) const fn get(self) -> u64 { self.0 } } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] pub(crate) enum PanelKind { Terminal, Browser, Markdown, Review, } impl PanelKind { pub(crate) const ALL: [Self; 4] = [Self::Terminal, Self::Browser, Self::Markdown, Self::Review]; pub(crate) const fn label(self) -> &'static str { match self { Self::Terminal => "Terminal", Self::Browser => "Browser", Self::Markdown => "Markdown", Self::Review => "Review", } } pub(crate) const fn description(self) -> &'static str { match self { Self::Terminal => "A real local shell with its own runtime actor", // Not a web engine. There is no browser in this build; the panel // renders a placeholder surface and says so. Self::Browser => "A placeholder surface. No web engine is embedded yet", Self::Markdown => "Local notes, plans, and architecture", Self::Review => "Repository changes and approval boundaries", } } /// What a newly created panel is pointed at. /// /// Every one of these used to name a specific machine — this developer's, /// as it happens. A panel's target is whatever it was actually launched /// against, and a new panel has been launched against nothing but the /// local shell. const fn default_target(self) -> &'static str { match self { Self::Terminal => "local shell", Self::Browser => "no surface attached", Self::Markdown => "local document", Self::Review => "working tree", } } } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] pub(crate) enum SeedPane { CodexRuntime, ClaudeUi, PiDocs, Architecture, AcpPreview, RuntimeReview, } impl SeedPane { pub(crate) const fn fixture_id(self) -> FixturePaneId { match self { Self::CodexRuntime => FixturePaneId::CodexRuntime, Self::ClaudeUi => FixturePaneId::ClaudeUi, Self::PiDocs => FixturePaneId::PiDocs, Self::Architecture => FixturePaneId::Architecture, Self::AcpPreview => FixturePaneId::AcpPreview, Self::RuntimeReview => FixturePaneId::RuntimeReview, } } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub(crate) struct WorkspacePanel { pub(crate) id: PanelId, pub(crate) kind: PanelKind, pub(crate) title: String, pub(crate) target: String, pub(crate) attached: bool, pub(crate) seed: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub(crate) struct PanelRegistry { schema_version: u32, panels: Vec, selected: PanelId, next_id: u64, } impl Default for PanelRegistry { fn default() -> Self { Self::first_run() } } impl PanelRegistry { /// The workspace a first run opens with. /// /// Three real local shells and nothing else. This used to seed six panes /// announcing "Codex · runtime / metal · Tailscale SSH", "Claude Code · UI /// / `MacBook` Air · local" and a Pi pane on a saved host — none of which /// existed. Every one was a `/bin/sh`. A pane claims a harness when one has /// actually been launched into it, and until then it is a terminal. /// /// Three, not one: `visible_panel_count` branches at 1100 px and 2800 px, /// so a single-panel default renders one pane on an ultrawide. pub(crate) fn first_run() -> Self { let seeds = [ (PanelKind::Terminal, "Terminal 1", "local shell", None), (PanelKind::Terminal, "Terminal 2", "local shell", None), (PanelKind::Terminal, "Terminal 3", "local shell", None), ]; let panels = seeds .into_iter() .enumerate() .map(|(index, (kind, title, target, seed))| WorkspacePanel { id: PanelId(index as u64 + 1), kind, title: title.to_owned(), target: target.to_owned(), attached: index < 5, seed, }) .collect(); Self { schema_version: 1, panels, selected: PanelId(1), next_id: 7, } } pub(crate) fn from_json(json: &str) -> Result { let registry: Self = serde_json::from_str(json).map_err(|error| error.to_string())?; registry.validate()?; Ok(registry) } pub(crate) fn to_json(&self) -> Result { serde_json::to_string(self).map_err(|error| error.to_string()) } fn validate(&self) -> Result<(), String> { if self.schema_version != 1 { return Err(format!( "unsupported panel snapshot version {}", self.schema_version )); } if self.panels.is_empty() || self.attached_count() == 0 { return Err("a workspace must retain one attached panel".to_owned()); } let ids = self .panels .iter() .map(|panel| panel.id) .collect::>(); if ids.len() != self.panels.len() || ids.iter().any(|id| id.get() == 0) { return Err("panel IDs must be unique and non-zero".to_owned()); } if self .panel(self.selected) .is_none_or(|panel| !panel.attached) { return Err("the selected panel must be attached".to_owned()); } let max_id = ids.iter().map(|id| id.get()).max().unwrap_or(0); if self.next_id <= max_id { return Err("the next panel ID must exceed every persisted ID".to_owned()); } Ok(()) } pub(crate) fn panels(&self) -> &[WorkspacePanel] { &self.panels } pub(crate) fn panel(&self, id: PanelId) -> Option<&WorkspacePanel> { self.panels.iter().find(|panel| panel.id == id) } pub(crate) fn selected(&self) -> PanelId { self.selected } pub(crate) fn attached_ids(&self) -> Vec { self.panels .iter() .filter(|panel| panel.attached) .map(|panel| panel.id) .collect() } pub(crate) fn detached_ids(&self) -> Vec { self.panels .iter() .filter(|panel| !panel.attached) .map(|panel| panel.id) .collect() } pub(crate) fn attached_count(&self) -> usize { self.panels.iter().filter(|panel| panel.attached).count() } pub(crate) fn select(&mut self, id: PanelId) -> bool { if self.panel(id).is_none_or(|panel| !panel.attached) || self.selected == id { return false; } self.selected = id; true } pub(crate) fn select_attached_at(&mut self, index: usize) -> bool { self.attached_ids() .get(index) .copied() .is_some_and(|id| self.select(id)) } pub(crate) fn move_horizontal(&mut self, delta: isize) -> bool { let attached = self.attached_ids(); let Some(position) = attached.iter().position(|id| *id == self.selected) else { return false; }; let next = position as isize + delta; if next < 0 || next >= attached.len() as isize { return false; } self.select(attached[next as usize]) } pub(crate) fn create(&mut self, kind: PanelKind) -> PanelId { let id = PanelId(self.next_id); self.next_id += 1; let sequence = self .panels .iter() .filter(|panel| panel.kind == kind) .count() + 1; let title = match kind { PanelKind::Terminal => format!("Terminal · {sequence}"), PanelKind::Browser => format!("Browser · {sequence}"), PanelKind::Markdown => format!("Notes · {sequence}"), PanelKind::Review => format!("Review · {sequence}"), }; let selected_position = self .panels .iter() .position(|panel| panel.id == self.selected) .expect("selected panel exists"); self.panels.insert( selected_position + 1, WorkspacePanel { id, kind, title, target: kind.default_target().to_owned(), attached: true, seed: None, }, ); self.selected = id; id } pub(crate) fn detach(&mut self, id: PanelId) -> bool { if self.attached_count() == 1 || self.panel(id).is_none_or(|panel| !panel.attached) { return false; } let attached_before = self.attached_ids(); let detached_position = attached_before .iter() .position(|candidate| *candidate == id) .expect("attached panel appears in attached IDs"); self.panels .iter_mut() .find(|panel| panel.id == id) .expect("panel exists") .attached = false; if self.selected == id { let remaining = self.attached_ids(); self.selected = remaining[detached_position.min(remaining.len() - 1)]; } true } pub(crate) fn attach(&mut self, id: PanelId) -> bool { let Some(panel) = self.panels.iter_mut().find(|panel| panel.id == id) else { return false; }; if panel.attached { return false; } panel.attached = true; self.selected = id; true } } #[cfg(test)] mod tests { use super::{PanelId, PanelKind, PanelRegistry}; #[test] fn dynamic_panels_receive_monotonic_ids_and_insert_beside_selection() { let mut registry = PanelRegistry::first_run(); let browser = registry.create(PanelKind::Browser); let terminal = registry.create(PanelKind::Terminal); assert_eq!(browser, PanelId(7)); assert_eq!(terminal, PanelId(8)); assert_eq!(registry.selected(), terminal); assert_eq!(registry.panels()[2].id, terminal); assert_eq!(registry.panels()[1].id, browser); } #[test] fn identity_and_detached_state_round_trip_without_reuse() { let mut registry = PanelRegistry::first_run(); let created = registry.create(PanelKind::Markdown); assert!(registry.detach(created)); let json = registry.to_json().unwrap(); let mut restored = PanelRegistry::from_json(&json).unwrap(); assert!(restored.detached_ids().contains(&created)); assert_eq!(restored.create(PanelKind::Review), PanelId(8)); } #[test] fn detach_preserves_panel_and_reattach_restores_same_identity() { let mut registry = PanelRegistry::first_run(); let id = registry.create(PanelKind::Terminal); assert!(registry.detach(id)); assert!(registry.panel(id).is_some()); assert!(registry.attach(id)); assert_eq!(registry.selected(), id); } #[test] fn invalid_or_empty_snapshots_are_rejected() { assert!(PanelRegistry::from_json("{}").is_err()); assert!( PanelRegistry::from_json( r#"{"schema_version":1,"panels":[],"selected":1,"next_id":2}"# ) .is_err() ); } }