This commit is contained in:
@@ -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"}]}"#)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
Generated
+60
@@ -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"
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
+553
-108
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user