feat: add persistent dynamic panels
CI / rust (push) Successful in 2m10s

This commit is contained in:
2026-08-31 18:43:36 -07:00
parent 4ed7613b22
commit 3409cabb80
13 changed files with 1125 additions and 133 deletions
+63
View File
@@ -75,6 +75,12 @@ CREATE TABLE IF NOT EXISTS workspace_buzz_channels (
channel_name TEXT NOT NULL, channel_name TEXT NOT NULL,
PRIMARY KEY (workspace_id, account_id, channel_id) PRIMARY KEY (workspace_id, account_id, channel_id)
) STRICT; ) STRICT;
CREATE TABLE IF NOT EXISTS workspace_snapshots (
workspace_id TEXT PRIMARY KEY NOT NULL,
snapshot_json TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;
"; ";
#[derive(Debug)] #[derive(Debug)]
@@ -222,6 +228,44 @@ impl Store {
.transpose() .transpose()
} }
/// Save a credential-free UI snapshot for a workspace.
///
/// The caller owns the versioned JSON shape. This storage boundary treats
/// it as opaque local state and must never receive secrets or transcripts.
///
/// # Errors
///
/// Returns an error when `SQLite` cannot insert or update the snapshot.
pub fn save_workspace_snapshot(&self, workspace_id: &str, snapshot_json: &str) -> Result<()> {
self.connection.execute(
r"
INSERT INTO workspace_snapshots (workspace_id, snapshot_json)
VALUES (?1, ?2)
ON CONFLICT(workspace_id) DO UPDATE SET
snapshot_json = excluded.snapshot_json,
updated_at = CURRENT_TIMESTAMP
",
params![workspace_id, snapshot_json],
)?;
Ok(())
}
/// Load the last credential-free UI snapshot for a workspace.
///
/// # Errors
///
/// Returns an error when `SQLite` cannot read the snapshot.
pub fn workspace_snapshot(&self, workspace_id: &str) -> Result<Option<String>> {
self.connection
.query_row(
"SELECT snapshot_json FROM workspace_snapshots WHERE workspace_id = ?1",
[workspace_id],
|row| row.get(0),
)
.optional()
.map_err(StorageError::from)
}
#[cfg(test)] #[cfg(test)]
fn contains_column(&self, table: &str, column: &str) -> Result<bool> { fn contains_column(&self, table: &str, column: &str) -> Result<bool> {
let mut statement = self let mut statement = self
@@ -281,4 +325,23 @@ mod tests {
); );
} }
} }
#[test]
fn workspace_snapshot_round_trips_as_opaque_local_state() {
let store = Store::open_in_memory().expect("store should initialize");
assert_eq!(store.workspace_snapshot("lumbridge-code").unwrap(), None);
store
.save_workspace_snapshot(
"lumbridge-code",
r#"{"selected":7,"panels":[{"id":7,"kind":"terminal"}]}"#,
)
.unwrap();
assert_eq!(
store
.workspace_snapshot("lumbridge-code")
.unwrap()
.as_deref(),
Some(r#"{"selected":7,"panels":[{"id":7,"kind":"terminal"}]}"#)
);
}
} }
+6 -1
View File
@@ -95,7 +95,12 @@ Panel attachment is independent from session lifetime. Detaching removes a pane
from the layout tree but retains its definition in the workspace's detached from the layout tree but retains its definition in the workspace's detached
registry and leaves its runtime-owned process alive. Reattaching restores that registry and leaves its runtime-owned process alive. Reattaching restores that
identity without a launch. Terminating an attached or detached session is a identity without a launch. Terminating an attached or detached session is a
separate Execute-capability operation. See decision 0010. separate Execute-capability operation. The GPUI slice now layers an unbounded
panel registry above the six-surface comparison fixture. New Terminal, Browser,
Markdown, and Review panels receive monotonic local IDs, are inserted beside
the selection, and persist their identity, order, selection, and attachment in
a credential-free SQLite snapshot. Each Terminal panel is keyed into the
runtime registry by that durable panel ID. See decisions 0010 and 0011.
We should evaluate, not blindly copy, WezTerm, Zellij, RMUX, tmux, and cmux. The We should evaluate, not blindly copy, WezTerm, Zellij, RMUX, tmux, and cmux. The
first spike must compare a reusable terminal crate with a small first-party layer. first spike must compare a reusable terminal crate with a small first-party layer.
+4
View File
@@ -6,6 +6,10 @@ Buzz is Lumbridge's first-class collaboration surface, not its control plane.
Lumbridge remains useful offline, owns its local workspace state, and never Lumbridge remains useful offline, owns its local workspace state, and never
requires a Buzz account to open a terminal or run an agent. requires a Buzz account to open a terminal or run an agent.
Lumbridge Code engineering uses the canonical `lumbridgecode` channel on the
cloud-1 Buzz community. Buzz is the collaboration product; Lumbridge does not
refer to or depend on Slack.
The initial integration provides: The initial integration provides:
- connect an existing Buzz identity using an OS credential-store reference; - connect an existing Buzz identity using an OS credential-store reference;
+6 -3
View File
@@ -124,9 +124,12 @@ who already have the final cargo-watch release installed.
- The GPUI slice tests its key-event adapter, responsive one/three/five-panel - The GPUI slice tests its key-event adapter, responsive one/three/five-panel
geometry, selected-panel windowing, per-panel 20/60/20 PTY sizing, xterm geometry, selected-panel windowing, per-panel 20/60/20 PTY sizing, xterm
256-color conversion, styled-run coalescing, cursor-run boundaries, and 256-color conversion, styled-run coalescing, cursor-run boundaries, and
scrollback shortcut routing. It renders three independently actor-owned VT scrollback shortcut routing. Its panel registry tests monotonic identity,
sessions while three non-terminal surfaces continue their deterministic insert-beside-selection, detach/reattach identity preservation, snapshot
background workload. validation, SQLite round-trip, and refusal to reuse a restored ID. It renders
an independent actor-owned VT session for every Terminal panel while the
original three non-terminal surfaces continue their deterministic background
workload.
- `lumbridge-terminal` retains 10,000 history lines by default and tests - `lumbridge-terminal` retains 10,000 history lines by default and tests
framework-neutral page/top/bottom viewport movement, revision changes, and framework-neutral page/top/bottom viewport movement, revision changes, and
live-bottom no-ops without sending history-navigation bytes to the child PTY. live-bottom no-ops without sending history-navigation bytes to the child PTY.
+7 -4
View File
@@ -90,10 +90,13 @@ fallback.
## Measurement semantics ## Measurement semantics
Both renderers retain the same all-deterministic six-surface action stream for Both renderers retain the same all-deterministic six-surface action stream for
comparison. GPUI now presents one, three, or five vertical panels, each with its comparison. GPUI now layers an unbounded, SQLite-snapshotted panel registry over
own 20/60/20 context/work/decision composition, while all three terminal that benchmark and presents one, three, or five vertical panels, each with its
fixtures are independent actor-owned VT sessions and the three non-terminal own 20/60/20 context/work/decision composition. The Add Panel chooser creates
surfaces keep deterministic updates running. Counters Terminal, Browser, Markdown, and Review panels beside the selection or
reattaches an existing session. Every Terminal panel receives an independent
actor-owned VT session; the three seed non-terminal surfaces keep deterministic
updates running. Counters
separate external PTY batches/lines from total model updates. The GPUI footer separate external PTY batches/lines from total model updates. The GPUI footer
reports dispatch-to-element-build p50/p95 over a bounded 256-sample window. It reports dispatch-to-element-build p50/p95 over a bounded 256-sample window. It
is deliberately not called key-to-present or frame-present latency: neither is deliberately not called key-to-present or frame-present latency: neither
+10 -6
View File
@@ -20,9 +20,12 @@ decision-shelf actions, usage-detail provenance, and end-to-end platform
accessibility/IME. accessibility/IME.
Panel lifecycle is now visible in the slice: every panel has a Detach action, Panel lifecycle is now visible in the slice: every panel has a Detach action,
the workspace bar has Add panel, and the sidebar exposes Detached sessions for the workspace bar opens an Add Panel chooser for Terminal, Browser, Markdown,
reattachment. The wording is deliberate because panel removal keeps its session and Review, and the sidebar and chooser expose Detached sessions for
alive; termination is not exposed as a visually equivalent close action. reattachment. Created panels are inserted beside the selection and receive a
monotonic identity persisted with their ordering and attachment state in local
SQLite. The wording is deliberate because panel removal keeps its session alive;
termination is not exposed as a visually equivalent close action.
Screenshots for the audit are stored outside Git under Screenshots for the audit are stored outside Git under
`~/shots/2026-08/lumbridge-ui-audit/`. Accessibility and IME correctness cannot `~/shots/2026-08/lumbridge-ui-audit/`. Accessibility and IME correctness cannot
@@ -87,7 +90,7 @@ surface rather than a hosted Lumbridge control plane.
leaving ordinary terminal arrows and text available to the PTY. leaving ordinary terminal arrows and text available to the PTY.
- `Alt+1` through `Alt+6`: focus a pane directly while leaving terminal digits - `Alt+1` through `Alt+6`: focus a pane directly while leaving terminal digits
available to the PTY. available to the PTY.
- `Alt+Shift+N`: add or reattach the next panel in the deterministic slice. - `Alt+Shift+N`: open the Add Panel chooser; `1``4` create a typed panel.
- `Alt+Shift+W`: detach the selected panel while its session keeps running. - `Alt+Shift+W`: detach the selected panel while its session keeps running.
- `Cmd+K` on macOS or `Ctrl+K` on Linux: open the command palette. - `Cmd+K` on macOS or `Ctrl+K` on Linux: open the command palette.
- `Shift+PageUp/PageDown`: move the selected live terminal through retained - `Shift+PageUp/PageDown`: move the selected live terminal through retained
@@ -98,8 +101,9 @@ surface rather than a hosted Lumbridge control plane.
separate explicit action and is never triggered by focusing the pane. separate explicit action and is never triggered by focusing the pane.
- `Escape` closes transient UI before it changes workspace state. - `Escape` closes transient UI before it changes workspace state.
Every command is represented by the shared interaction model so GPUI and Floem The six seed surfaces still replay through the shared interaction model so GPUI
receive the same state transitions and tests. and Floem retain a comparable benchmark. Dynamic product panels use the GPUI
panel registry until that contract moves into the framework-neutral UI crate.
## Decisive workload ## Decisive workload
@@ -22,6 +22,7 @@ derives approximately 71 columns by 42 rows per panel.
The runtime slice now owns three independent real PTYs through a pane-indexed The runtime slice now owns three independent real PTYs through a pane-indexed
session registry, one for every terminal fixture. The Markdown, browser, and session registry, one for every terminal fixture. The Markdown, browser, and
review fixtures remain deterministic comparison surfaces. Adding arbitrary new review fixtures remain deterministic comparison surfaces. The GPUI product
terminal panels requires replacing the fixed fixture identity pool with dynamic slice now adds arbitrary panels through a persistent dynamic identity layer;
pane creation; it does not require another runtime ownership design. each additional Terminal panel is another entry in the same runtime ownership
design. The fixed fixture remains only as the GPUI/Floem benchmark input.
@@ -15,12 +15,11 @@ confirmation that names the process, target machine, and any dirty or waiting
state. Detaching the selected panel chooses its next attached sibling, falling state. Detaching the selected panel chooses its next attached sibling, falling
back to the previous sibling. A workspace retains at least one attached panel. back to the previous sibling. A workspace retains at least one attached panel.
The add affordance has two product paths: quick activation creates the user's The add affordance opens a chooser for Terminal, Browser, Markdown, Review, and
default panel beside the selection, while its menu offers Terminal, Browser, Reattach running session. The deterministic comparison model still uses six
Markdown, Review, and Reattach running session. The deterministic UI spike uses seed fixtures and starts with five attached. The GPUI slice no longer inherits
six fixed fixtures, starts with five attached, and reattaches the first detached that fixture limit: Add Panel creates a new persistent panel identity beside the
fixture when Add panel is activated. That fixture limit is test scaffolding, not selection or reattaches the exact detached identity without relaunching it.
a product limit.
The typed command plane retains detached pane definitions separately from the The typed command plane retains detached pane definitions separately from the
layout tree. `ClosePane(Detach)` moves a definition into that registry, layout tree. `ClosePane(Detach)` moves a definition into that registry,
@@ -0,0 +1,23 @@
# 0011: Dynamic panels have persistent local identities
Status: accepted for the GPUI product slice.
The six-surface fixture remains a deterministic benchmark shared with Floem; it
is not the product's panel database. GPUI layers an unbounded workspace registry
above it. Add Panel creates Terminal, Browser, Markdown, or Review beside the
selected panel. Each new panel receives a monotonically increasing `PanelId`
that is never derived from its title, position, surface, process ID, or visible
index.
The registry persists panel identity, kind, title, target, order, attachment,
selection, and the next unused ID as one versioned, credential-free SQLite
snapshot. Invalid snapshots fail closed to the seed layout rather than partially
restoring aliases. SQLite never stores terminal output, credentials, private
keys, subscription tokens, or Buzz identity secrets through this boundary.
Every Terminal panel uses its `PanelId` as the runtime-registry key. Detaching
changes only layout attachment, so the actor and PTY keep running. Reattaching
selects the same ID. A restored in-process spike must currently launch a new PTY
for that persisted panel because actor durability across UI process restarts
requires the planned separate Lumbridge runtime and authenticated IPC; the UI
does not pretend otherwise.
+60
View File
@@ -1616,6 +1616,18 @@ dependencies = [
"zune-inflate", "zune-inflate",
] ]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "1.9.0" version = "1.9.0"
@@ -2961,6 +2973,17 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "libsqlite3-sys"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.15" version = "0.4.15"
@@ -3013,6 +3036,13 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lumbridge-core"
version = "0.0.1"
dependencies = [
"thiserror 2.0.20",
]
[[package]] [[package]]
name = "lumbridge-pty" name = "lumbridge-pty"
version = "0.0.1" version = "0.0.1"
@@ -3037,13 +3067,24 @@ dependencies = [
"gpui", "gpui",
"lumbridge-runtime", "lumbridge-runtime",
"lumbridge-spike-model", "lumbridge-spike-model",
"lumbridge-storage",
"lumbridge-terminal", "lumbridge-terminal",
"serde",
"serde_json",
] ]
[[package]] [[package]]
name = "lumbridge-spike-model" name = "lumbridge-spike-model"
version = "0.0.1" version = "0.0.1"
[[package]]
name = "lumbridge-storage"
version = "0.0.1"
dependencies = [
"lumbridge-core",
"rusqlite",
]
[[package]] [[package]]
name = "lumbridge-terminal" name = "lumbridge-terminal"
version = "0.0.1" version = "0.0.1"
@@ -4464,6 +4505,19 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rusqlite"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"bitflags 2.13.1",
"fallible-iterator",
"fallible-streaming-iterator",
"libsqlite3-sys",
"smallvec",
]
[[package]] [[package]]
name = "rust-embed" name = "rust-embed"
version = "8.12.0" version = "8.12.0"
@@ -6028,6 +6082,12 @@ dependencies = [
"sval_serde", "sval_serde",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
+3
View File
@@ -11,6 +11,9 @@ publish = false
gpui = "0.2.2" gpui = "0.2.2"
lumbridge-runtime = { path = "../../crates/lumbridge-runtime" } lumbridge-runtime = { path = "../../crates/lumbridge-runtime" }
lumbridge-spike-model = { path = "../ui-shell-model" } lumbridge-spike-model = { path = "../ui-shell-model" }
lumbridge-storage = { path = "../../crates/lumbridge-storage" }
lumbridge-terminal = { path = "../../crates/lumbridge-terminal" } lumbridge-terminal = { path = "../../crates/lumbridge-terminal" }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
[workspace] [workspace]
File diff suppressed because it is too large Load Diff
+379
View File
@@ -0,0 +1,379 @@
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",
Self::Browser => "An isolated system-web-engine surface",
Self::Markdown => "Local notes, plans, and architecture",
Self::Review => "Repository changes and approval boundaries",
}
}
const fn default_target(self) -> &'static str {
match self {
Self::Terminal => "metal · local runtime",
Self::Browser => "isolated system web engine",
Self::Markdown => "lumbridge-code · local document",
Self::Review => "lumbridge-code · 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<SeedPane>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub(crate) struct PanelRegistry {
schema_version: u32,
panels: Vec<WorkspacePanel>,
selected: PanelId,
next_id: u64,
}
impl Default for PanelRegistry {
fn default() -> Self {
Self::seeded()
}
}
impl PanelRegistry {
pub(crate) fn seeded() -> Self {
let seeds = [
(
PanelKind::Terminal,
"Codex · runtime",
"metal · Tailscale SSH",
SeedPane::CodexRuntime,
),
(
PanelKind::Terminal,
"Claude Code · UI",
"MacBook Air · local",
SeedPane::ClaudeUi,
),
(
PanelKind::Terminal,
"Pi · docs",
"amd-server · OpenSSH",
SeedPane::PiDocs,
),
(
PanelKind::Markdown,
"Architecture.md",
"lumbridge-code · worktree",
SeedPane::Architecture,
),
(
PanelKind::Browser,
"Preview · ACP docs",
"isolated system web engine",
SeedPane::AcpPreview,
),
(
PanelKind::Review,
"Changes · lumbridge-runtime",
"metal · worktree remote-runtime",
SeedPane::RuntimeReview,
),
];
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: Some(seed),
})
.collect();
Self {
schema_version: 1,
panels,
selected: PanelId(1),
next_id: 7,
}
}
pub(crate) fn from_json(json: &str) -> Result<Self, String> {
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<String, String> {
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::<BTreeSet<_>>();
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<PanelId> {
self.panels
.iter()
.filter(|panel| panel.attached)
.map(|panel| panel.id)
.collect()
}
pub(crate) fn detached_ids(&self) -> Vec<PanelId> {
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::seeded();
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()[3].id, browser);
}
#[test]
fn identity_and_detached_state_round_trip_without_reuse() {
let mut registry = PanelRegistry::seeded();
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::seeded();
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()
);
}
}