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
+210 -62
View File
@@ -10,8 +10,8 @@ use lumbridge_runtime::{
RuntimeCommand, RuntimeEvent, TerminalSize,
};
use lumbridge_spike_model::{
ActionOutcome, FOOTER_RIGHT, FocusDirection, OutputSource, PaneId, PaneState, ShellAction,
ShellModel, SurfaceKind, WORKSPACES,
ActionOutcome, FOOTER_RIGHT, FocusDirection, INITIAL_ATTACHED_PANEL_COUNT, OutputSource,
PaneId, PaneState, ShellAction, ShellModel, SurfaceKind, WORKSPACES,
};
use lumbridge_terminal::{
KeyModifiers, TerminalCellStyle, TerminalColor, TerminalCursorShape, TerminalDimensions,
@@ -62,6 +62,8 @@ actions!(
SelectPane4,
SelectPane5,
SelectPane6,
AddPanel,
DetachSelectedPanel,
TerminalTaller,
TerminalShorter,
TerminalWider,
@@ -272,14 +274,18 @@ fn dim_color(color: u32) -> u32 {
(dim((color >> 16) & 0xff) << 16) | (dim((color >> 8) & 0xff) << 8) | dim(color & 0xff)
}
fn terminal_dimensions_for_window(window_size: Size<Pixels>) -> TerminalDimensions {
fn terminal_dimensions_for_window(
window_size: Size<Pixels>,
attached_panel_count: usize,
) -> TerminalDimensions {
let width = f32::from(window_size.width);
let height = f32::from(window_size.height);
let panel_count = visible_panel_count(window_size);
let panel_count = visible_panel_count(window_size)
.min(attached_panel_count)
.max(1);
let workspace_height =
(height - APP_HEADER_HEIGHT - TAB_BAR_HEIGHT - APP_FOOTER_HEIGHT).max(0.0);
let terminal_height =
(workspace_height * 0.60 - TERMINAL_CONTENT_VERTICAL_INSET).max(0.0);
let terminal_height = (workspace_height * 0.60 - TERMINAL_CONTENT_VERTICAL_INSET).max(0.0);
let workspace_width = (width - SIDEBAR_WIDTH).max(0.0);
let panel_gaps = WORK_PANEL_GAP * panel_count.saturating_sub(1) as f32;
let panel_width = ((workspace_width - panel_gaps).max(0.0) / panel_count as f32).max(0.0);
@@ -401,7 +407,8 @@ impl LumbridgeShell {
})
.detach();
let terminal_dimensions = terminal_dimensions_for_window(window.bounds().size);
let terminal_dimensions =
terminal_dimensions_for_window(window.bounds().size, INITIAL_ATTACHED_PANEL_COUNT);
let terminal = TerminalEngine::new(TerminalEngineOptions {
dimensions: terminal_dimensions,
..TerminalEngineOptions::default()
@@ -413,7 +420,10 @@ impl LumbridgeShell {
};
cx.observe_window_bounds(window, |shell, window, cx| {
let dimensions = terminal_dimensions_for_window(window.bounds().size);
let dimensions = terminal_dimensions_for_window(
window.bounds().size,
shell.model.attached_panel_count(),
);
shell.resize_terminal(dimensions.rows(), dimensions.columns(), cx);
})
.detach();
@@ -559,6 +569,50 @@ impl LumbridgeShell {
cx.notify();
}
fn resize_terminal_for_workspace(&mut self, window: &Window, cx: &mut Context<Self>) {
let dimensions =
terminal_dimensions_for_window(window.bounds().size, self.model.attached_panel_count());
self.resize_terminal(dimensions.rows(), dimensions.columns(), cx);
}
fn attach_panel(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) {
let outcome = self.dispatch(ShellAction::AttachPanel(pane));
if outcome.changed {
self.resize_terminal_for_workspace(window, cx);
}
window.focus(&self.root_focus);
cx.notify();
}
fn add_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(pane) = self.model.next_detached_panel() else {
return;
};
self.attach_panel(pane, window, cx);
}
fn detach_panel(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) {
let outcome = self.dispatch(ShellAction::DetachPanel(pane));
if outcome.changed {
self.resize_terminal_for_workspace(window, cx);
}
window.focus(&self.root_focus);
cx.notify();
}
fn add_panel_action(&mut self, _: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
self.add_panel(window, cx);
}
fn detach_selected_panel(
&mut self,
_: &DetachSelectedPanel,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.detach_panel(self.model.selected_pane(), window, cx);
}
fn move_focus(
&mut self,
direction: FocusDirection,
@@ -641,10 +695,9 @@ impl LumbridgeShell {
return;
}
let modifiers = key_modifiers(event);
if let Some(scroll) = terminal_scroll_from_parts(
event.keystroke.key.as_str(),
modifiers,
) {
if let Some(scroll) =
terminal_scroll_from_parts(event.keystroke.key.as_str(), modifiers)
{
self.scroll_terminal(scroll, cx);
return;
}
@@ -768,7 +821,14 @@ impl LumbridgeShell {
.into_any_element()
}
fn pane_context(&self, pane: &PaneState, selected: bool) -> gpui::AnyElement {
fn pane_context(
&self,
pane: &PaneState,
selected: bool,
cx: &mut Context<Self>,
) -> gpui::AnyElement {
let pane_id = pane.id();
let can_detach = self.model.attached_panel_count() > 1;
let external = pane.output_source() == OutputSource::External;
let status = if external {
self.runtime_status.badge()
@@ -859,16 +919,44 @@ impl LumbridgeShell {
)
.child(
div()
.flex()
.flex_col()
.items_end()
.flex_none()
.text_xs()
.text_color(rgb(if pane.needs_input() {
ATTENTION
} else if selected {
SUCCESS
} else {
MUTED
}))
.child(surface_status),
.gap_1()
.child(
div()
.text_xs()
.text_color(rgb(if pane.needs_input() {
ATTENTION
} else if selected {
SUCCESS
} else {
MUTED
}))
.child(surface_status),
)
.child(
div()
.id(("detach-panel", pane_id.index()))
.cursor_pointer()
.px_2()
.py_1()
.rounded(px(4.0))
.border_1()
.border_color(rgb(BORDER))
.text_xs()
.text_color(rgb(if can_detach { MUTED } else { BORDER }))
.child(if can_detach {
" DETACH"
} else {
"LAST PANEL"
})
.on_click(cx.listener(move |shell, _, window, cx| {
cx.stop_propagation();
shell.detach_panel(pane_id, window, cx);
})),
),
),
)
.child(
@@ -990,18 +1078,10 @@ impl LumbridgeShell {
.items_center()
.justify_between()
.text_xs()
.child(div().text_color(rgb(MUTED)).child("DECISION SHELF"))
.child(
div()
.text_color(rgb(MUTED))
.child("DECISION SHELF"),
)
.child(
div()
.text_color(rgb(if pane.needs_input() {
ATTENTION
} else {
MUTED
}))
.text_color(rgb(if pane.needs_input() { ATTENTION } else { MUTED }))
.child(if pane.needs_input() {
"REVIEW REQUIRED"
} else {
@@ -1009,16 +1089,9 @@ impl LumbridgeShell {
}),
),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.mt_2()
.children(choices.map(|(label, detail, attention)| {
choice(label, detail, attention)
})),
)
.child(div().flex().flex_col().gap_1().mt_2().children(
choices.map(|(label, detail, attention)| choice(label, detail, attention)),
))
.into_any_element()
}
@@ -1048,7 +1121,7 @@ impl LumbridgeShell {
.flex_none()
.min_h_0()
.overflow_hidden()
.child(self.pane_context(pane, selected)),
.child(self.pane_context(pane, selected, cx)),
)
.child(
div()
@@ -1128,7 +1201,26 @@ impl LumbridgeShell {
.px_3()
.py_2()
.text_color(rgb(MUTED))
.child("Open attention request"),
.child("Add workspace panel")
.child(
div()
.mt_1()
.text_xs()
.child("Alt+Shift+N · reattach if detached"),
),
)
.child(
div()
.px_3()
.py_2()
.text_color(rgb(MUTED))
.child("Detach selected panel")
.child(
div()
.mt_1()
.text_xs()
.child("Alt+Shift+W · session keeps running"),
),
)
.child(
div()
@@ -1158,7 +1250,17 @@ impl LumbridgeShell {
impl Render for LumbridgeShell {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let attention = self.model.pane(PaneId::ClaudeUi);
let panel_count = visible_panel_count(window.bounds().size);
let panel_capacity = visible_panel_count(window.bounds().size);
let attached_panes = self.model.attached_pane_ids();
let attached_count = attached_panes.len();
let detached_entries = self
.model
.detached_pane_ids()
.into_iter()
.map(|pane| (pane, self.model.pane(pane).fixture().title))
.collect::<Vec<_>>();
let detached_count = detached_entries.len();
let visible_count = panel_capacity.min(attached_count).max(1);
let sidebar = div()
.flex()
.flex_col()
@@ -1263,6 +1365,33 @@ impl Render for LumbridgeShell {
.text_color(rgb(MUTED))
.child("spark-1 · sleeping"),
)
.child(
div()
.mt_3()
.px_4()
.py_2()
.text_xs()
.text_color(rgb(MUTED))
.child(format!("DETACHED SESSIONS · {detached_count}")),
)
.children(detached_entries.into_iter().map(|(pane, title)| {
div()
.id(("detached-session", pane.index()))
.cursor_pointer()
.mx_2()
.mb_1()
.px_3()
.py_2()
.rounded(px(4.0))
.border_1()
.border_color(rgb(BORDER_QUIET))
.text_xs()
.text_color(rgb(MUTED))
.child(format!("{title}"))
.on_click(cx.listener(move |shell, _, window, cx| {
shell.attach_panel(pane, window, cx);
}))
}))
.child(div().flex_1())
.child(
div()
@@ -1317,33 +1446,44 @@ impl Render for LumbridgeShell {
.child("Review"),
)
.child(div().flex_1())
.child(div().px_3().text_xs().text_color(rgb(MUTED)).child(format!(
"{visible_count} shown · {attached_count} attached · {detached_count} detached"
)))
.child(
div()
.id("add-workspace-panel")
.cursor_pointer()
.ml_2()
.px_3()
.py_1()
.rounded(px(4.0))
.border_1()
.border_color(rgb(if detached_count > 0 { ACCENT } else { BORDER }))
.text_xs()
.text_color(rgb(MUTED))
.child(format!(
"{panel_count} visible panels · 1 interactive VT · 1 waiting"
)),
.text_color(rgb(if detached_count > 0 { ACCENT } else { MUTED }))
.child(if detached_count > 0 {
"+ ADD PANEL"
} else {
"ALL PANELS ATTACHED"
})
.on_click(cx.listener(|shell, _, window, cx| {
shell.add_panel(window, cx);
})),
);
let panel_range = visible_panel_range(
PaneId::ALL.len(),
self.model.selected_pane().index(),
panel_count,
);
let selected_position = attached_panes
.iter()
.position(|pane| *pane == self.model.selected_pane())
.expect("the selected pane must remain attached");
let panel_range = visible_panel_range(attached_count, selected_position, visible_count);
let workspace_panels = panel_range
.map(|index| {
let pane = self.model.pane(PaneId::ALL[index]);
let pane = self.model.pane(attached_panes[index]);
div()
.flex_1()
.min_w_0()
.min_h_0()
.child(self.workspace_panel(
pane,
pane.id() == self.model.selected_pane(),
cx,
))
.child(self.workspace_panel(pane, pane.id() == self.model.selected_pane(), cx))
})
.collect::<Vec<_>>();
let workspace_row = div()
@@ -1380,6 +1520,8 @@ impl Render for LumbridgeShell {
.on_action(cx.listener(Self::focus_down))
.on_action(cx.listener(Self::open_palette))
.on_action(cx.listener(Self::close_palette))
.on_action(cx.listener(Self::add_panel_action))
.on_action(cx.listener(Self::detach_selected_panel))
.on_action(cx.listener(Self::terminal_taller))
.on_action(cx.listener(Self::terminal_shorter))
.on_action(cx.listener(Self::terminal_wider))
@@ -1570,6 +1712,8 @@ fn main() {
KeyBinding::new("alt-4", SelectPane4, Some("LumbridgeShell")),
KeyBinding::new("alt-5", SelectPane5, Some("LumbridgeShell")),
KeyBinding::new("alt-6", SelectPane6, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-n", AddPanel, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-w", DetachSelectedPanel, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-up", TerminalTaller, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-down", TerminalShorter, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-right", TerminalWider, Some("LumbridgeShell")),
@@ -1626,15 +1770,19 @@ mod tests {
#[test]
fn terminal_geometry_tracks_middle_sixty_percent_per_panel() {
let dimensions = terminal_dimensions_for_window(size(px(1500.0), px(960.0)));
let dimensions = terminal_dimensions_for_window(size(px(1500.0), px(960.0)), 5);
assert_eq!(dimensions.rows(), 26);
assert_eq!(dimensions.columns(), 45);
let ultrawide = size(px(3440.0), px(1440.0));
assert_eq!(visible_panel_count(ultrawide), 5);
let dimensions = terminal_dimensions_for_window(ultrawide);
let dimensions = terminal_dimensions_for_window(ultrawide, 5);
assert_eq!(dimensions.rows(), 42);
assert_eq!(dimensions.columns(), 71);
let two_panels = terminal_dimensions_for_window(ultrawide, 2);
assert_eq!(two_panels.rows(), 42);
assert_eq!(two_panels.columns(), 185);
}
#[test]