The product was spikes/gpui-shell: a cargo workspace of its own, named in the root manifest's exclude list. It inherited neither unsafe_code = "forbid" nor clippy pedantic, and ./scripts/ci.sh never compiled it. Every test written into it silently never ran, and apps/lumbridge was an eleven-line stub printing a version string. Four separate research passes over the sidebar, settings, devices, and theme work independently discovered they were about to write substantial new code into that directory. Graduating first means writing it once. - apps/lumbridge is the product; spikes/ui-shell-model becomes crates/lumbridge-ui-fixture and joins the workspace. - scripts/ci.sh takes --headless and --ui. The headless pass excludes the two UI crates by name, so a contributor changing lumbridge-core does not wait on a window toolkit, and a runner that cannot carry GPUI still gates everything else. A new crate is headless by default rather than silently joining the slow job. - scripts/native-libs.sh replaces the ad-hoc symlink in the launcher, and says which apt package actually fixes the problem instead of working around it silently. The stale libxcb/libxkbcommon symlinks in the old spike target directory are gone; only libxkbcommon-x11.so was ever needed. - deny.toml and cargo deny check licenses. spikes/README.md called GPUI's licence closure a hard gate and the scorecard scored it pending; graduation makes it the product's closure, so it is enforced rather than described. Two rejections were reviewed and allowed with the reasoning recorded in the file: webpki-roots under CDLA-Permissive-2.0 (Mozilla's CA store, data not code, reached through ureq) and libfuzzer-sys under NCSA (reached only under all-features via gpui's image decoder; no shipped build links it). Clippy pedantic across both crates is clean at -D warnings. render was 353 lines; render_sidebar, render_tabs, and render_root come out of it, which the sidebar rework needed anyway. The remaining over-length functions are single declarative element trees and carry per-function allows with reasons, not a blanket suppression. Decision 0017 records the two calls this forces: published gpui 0.2.2 behind an accessibility adapter rather than an unpinned Zed revision and an MSRV bump, and Floem frozen rather than maintained in parity or deleted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
486 lines
16 KiB
Rust
486 lines
16 KiB
Rust
use std::time::Duration;
|
|
|
|
use floem::{
|
|
Application,
|
|
action::exec_after,
|
|
event::EventPropagation,
|
|
kurbo::Size,
|
|
peniko::Color,
|
|
prelude::*,
|
|
reactive::{Effect, RwSignal, SignalGet, SignalTrack, SignalUpdate, SignalWith},
|
|
style::Position,
|
|
window::WindowConfig,
|
|
};
|
|
use lumbridge_ui_fixture::{
|
|
FOOTER_FIXTURE_NOTICE, FocusDirection, PaneId, PaneState, ShellAction, ShellModel, SurfaceKind,
|
|
};
|
|
|
|
const BG: Color = Color::from_rgb8(9, 12, 18);
|
|
const PANEL: Color = Color::from_rgb8(16, 22, 32);
|
|
const PANEL_ALT: Color = Color::from_rgb8(21, 29, 41);
|
|
const PANEL_ACTIVE: Color = Color::from_rgb8(24, 35, 52);
|
|
const BORDER: Color = Color::from_rgb8(38, 50, 70);
|
|
const BORDER_QUIET: Color = Color::from_rgb8(28, 38, 54);
|
|
const TEXT: Color = Color::from_rgb8(219, 229, 244);
|
|
const MUTED: Color = Color::from_rgb8(130, 144, 168);
|
|
const ACCENT: Color = Color::from_rgb8(104, 181, 248);
|
|
const ATTENTION: Color = Color::from_rgb8(241, 185, 106);
|
|
const SUCCESS: Color = Color::from_rgb8(112, 214, 168);
|
|
const OS_MOD: Modifiers = if cfg!(target_os = "macos") {
|
|
Modifiers::META
|
|
} else {
|
|
Modifiers::CONTROL
|
|
};
|
|
|
|
fn dispatch(model: RwSignal<ShellModel>, action: ShellAction) {
|
|
model.update(|model| {
|
|
model.dispatch(action);
|
|
});
|
|
}
|
|
|
|
fn pane_card(id: PaneId, model: RwSignal<ShellModel>) -> impl IntoView {
|
|
dyn_container(
|
|
move || model.with(|model| (model.pane(id).clone(), model.selected_pane() == id)),
|
|
move |(pane, selected)| render_pane_card(pane, selected, model),
|
|
)
|
|
.style(|s| {
|
|
s.flex_basis(0)
|
|
.flex_grow(1.0)
|
|
.min_width(0.0)
|
|
.min_height(0.0)
|
|
.margin(4.0)
|
|
})
|
|
}
|
|
|
|
fn render_pane_card(pane: PaneState, selected: bool, model: RwSignal<ShellModel>) -> impl IntoView {
|
|
let id = pane.id();
|
|
let needs_input = pane.needs_input();
|
|
let state_color = if needs_input {
|
|
ATTENTION
|
|
} else if matches!(pane.kind(), SurfaceKind::Terminal) {
|
|
SUCCESS
|
|
} else {
|
|
ACCENT
|
|
};
|
|
let state_label = if matches!(pane.kind(), SurfaceKind::Terminal) {
|
|
pane.status().label()
|
|
} else {
|
|
pane.fixture().badge
|
|
};
|
|
let start = pane.lines().len().saturating_sub(12);
|
|
let lines = Stack::from_iter(pane.lines()[start..].iter().cloned().map(|line| {
|
|
Label::new(line).style(|s| {
|
|
s.font_size(12.0)
|
|
.color(TEXT)
|
|
.margin_bottom(4.0)
|
|
.min_width(0.0)
|
|
})
|
|
}))
|
|
.style(|s| s.flex_col().padding_horiz(10.0).padding_bottom(10.0));
|
|
|
|
let header = Stack::horizontal((
|
|
Stack::horizontal((
|
|
Label::new(format!("{}", id.index() + 1))
|
|
.style(|s| s.font_size(11.0).color(MUTED).margin_right(8.0)),
|
|
Label::new(pane.fixture().title)
|
|
.style(|s| s.flex_grow(1.0).font_size(13.0).color(TEXT)),
|
|
))
|
|
.style(|s| s.items_center().min_width(0.0).flex_grow(1.0)),
|
|
Label::new(state_label).style(move |s| s.font_size(10.0).color(state_color)),
|
|
))
|
|
.style(move |s| {
|
|
s.items_center()
|
|
.height(36.0)
|
|
.padding_horiz(10.0)
|
|
.background(if selected { PANEL_ACTIVE } else { PANEL_ALT })
|
|
.border_bottom(1.0)
|
|
.border_color(BORDER_QUIET)
|
|
});
|
|
|
|
let target = Stack::horizontal((
|
|
Label::new(pane.fixture().target).style(|s| s.flex_grow(1.0).color(MUTED)),
|
|
Label::new(if selected { "FOCUSED" } else { "" })
|
|
.style(|s| s.font_size(10.0).color(ACCENT)),
|
|
))
|
|
.style(|s| {
|
|
s.items_center()
|
|
.padding_horiz(10.0)
|
|
.padding_vert(8.0)
|
|
.font_size(11.0)
|
|
});
|
|
|
|
Stack::vertical((header, target, lines))
|
|
.style(move |s| {
|
|
s.width_full()
|
|
.height_full()
|
|
.min_width(0.0)
|
|
.min_height(0.0)
|
|
.background(if selected { PANEL_ACTIVE } else { PANEL })
|
|
.border(if selected { 2.0 } else { 1.0 })
|
|
.border_color(if selected || needs_input {
|
|
state_color
|
|
} else {
|
|
BORDER
|
|
})
|
|
.border_radius(5.0)
|
|
.keyboard_navigable()
|
|
})
|
|
.on_event_stop(listener::Click, move |_, _| {
|
|
dispatch(model, ShellAction::SelectPane(id));
|
|
})
|
|
}
|
|
|
|
fn sidebar() -> impl IntoView {
|
|
// The worktree list this shell used to render came from a five-entry
|
|
// fixture naming this developer's machines, backed by no git integration.
|
|
// The GPUI shell's copy was deleted in the same pass; this one renders the
|
|
// workspace it is actually open on.
|
|
let worktrees = Stack::vertical((Label::new("Lumbridge Code"),)).style(|s| {
|
|
s.flex_col()
|
|
.padding_horiz(8.0)
|
|
.padding_vert(7.0)
|
|
.color(TEXT)
|
|
});
|
|
|
|
Stack::vertical((
|
|
Label::new("ATTENTION · 1").style(|s| s.font_size(10.0).color(ATTENTION).padding(12.0)),
|
|
Stack::vertical((
|
|
Label::new("Claude Code · UI").style(|s| s.color(TEXT)),
|
|
Label::new("Waiting for a split decision")
|
|
.style(|s| s.font_size(10.0).color(MUTED).margin_top(3.0)),
|
|
))
|
|
.style(|s| {
|
|
s.margin_horiz(8.0)
|
|
.margin_bottom(12.0)
|
|
.padding(10.0)
|
|
.background(PANEL_ACTIVE)
|
|
.border(1.0)
|
|
.border_color(ATTENTION)
|
|
.border_radius(5.0)
|
|
}),
|
|
Label::new("WORKTREES").style(|s| s.font_size(10.0).color(MUTED).padding(12.0)),
|
|
worktrees,
|
|
Label::new("RUNTIMES").style(|s| {
|
|
s.font_size(10.0)
|
|
.color(MUTED)
|
|
.padding_horiz(12.0)
|
|
.padding_vert(9.0)
|
|
.margin_top(8.0)
|
|
}),
|
|
Label::new("● metal · connected").style(|s| s.color(SUCCESS).padding_horiz(12.0)),
|
|
Label::new("● amd-server · connected")
|
|
.style(|s| s.color(MUTED).padding_horiz(12.0).margin_top(7.0)),
|
|
Label::new("○ spark-1 · sleeping")
|
|
.style(|s| s.color(MUTED).padding_horiz(12.0).margin_top(7.0)),
|
|
Empty::new().style(|s| s.flex_grow(1.0)),
|
|
Stack::vertical((
|
|
Label::new("Buzz · lumbridgecode"),
|
|
Label::new("connected · signed identity").style(|s| s.color(SUCCESS).margin_top(3.0)),
|
|
))
|
|
.style(|s| {
|
|
s.margin(10.0)
|
|
.padding(10.0)
|
|
.font_size(10.0)
|
|
.color(MUTED)
|
|
.background(PANEL_ALT)
|
|
.border_radius(5.0)
|
|
}),
|
|
))
|
|
.style(|s| {
|
|
s.width(248.0)
|
|
.height_full()
|
|
.flex_shrink(0.0)
|
|
.background(PANEL)
|
|
.border_right(1.0)
|
|
.border_color(BORDER_QUIET)
|
|
})
|
|
}
|
|
|
|
fn tabs() -> impl IntoView {
|
|
Stack::horizontal((
|
|
Label::new("Agent workspace").style(|s| {
|
|
s.height_full()
|
|
.items_center()
|
|
.padding_horiz(12.0)
|
|
.border_bottom(2.0)
|
|
.border_color(ACCENT)
|
|
.color(TEXT)
|
|
}),
|
|
Label::new("Architecture.md").style(|s| s.padding_horiz(12.0).color(MUTED)),
|
|
Label::new("Review").style(|s| s.padding_horiz(12.0).color(MUTED)),
|
|
Empty::new().style(|s| s.flex_grow(1.0)),
|
|
Label::new("6 surfaces · 3 remote · 1 waiting")
|
|
.style(|s| s.font_size(10.0).padding_horiz(12.0).color(MUTED)),
|
|
))
|
|
.style(|s| {
|
|
s.height(38.0)
|
|
.items_center()
|
|
.font_size(12.0)
|
|
.background(PANEL)
|
|
.border_bottom(1.0)
|
|
.border_color(BORDER_QUIET)
|
|
})
|
|
}
|
|
|
|
fn command_palette(query: RwSignal<String>) -> impl IntoView {
|
|
let input = TextInput::new(query)
|
|
.placeholder("Type a command…")
|
|
.style(|s| {
|
|
s.width_full()
|
|
.height(44.0)
|
|
.padding_horiz(12.0)
|
|
.color(TEXT)
|
|
.background(PANEL_ALT)
|
|
.border(0.0)
|
|
});
|
|
let input_id = input.id();
|
|
exec_after(Duration::from_millis(1), move |_| input_id.request_focus());
|
|
|
|
let card = Stack::vertical((
|
|
input,
|
|
Stack::vertical((
|
|
Stack::vertical((
|
|
Label::new("Focus next pane").style(|s| s.color(TEXT)),
|
|
Label::new("Workspace · navigation")
|
|
.style(|s| s.font_size(10.0).color(MUTED).margin_top(3.0)),
|
|
))
|
|
.style(|s| s.padding(10.0).background(PANEL_ACTIVE).border_radius(5.0)),
|
|
Label::new("Open attention request").style(|s| s.padding(10.0).color(MUTED)),
|
|
Label::new("Share selected pane to Buzz…").style(|s| s.padding(10.0).color(MUTED)),
|
|
))
|
|
.style(|s| {
|
|
s.padding(8.0)
|
|
.border_top(1.0)
|
|
.border_bottom(1.0)
|
|
.border_color(BORDER)
|
|
}),
|
|
Stack::horizontal((
|
|
Label::new("Enter to run").style(|s| s.flex_grow(1.0)),
|
|
Label::new("Esc to close"),
|
|
))
|
|
.style(|s| {
|
|
s.padding_horiz(12.0)
|
|
.padding_vert(8.0)
|
|
.font_size(10.0)
|
|
.color(MUTED)
|
|
}),
|
|
))
|
|
.style(|s| {
|
|
s.width(620.0)
|
|
.margin_top(92.0)
|
|
.background(PANEL_ALT)
|
|
.border(1.0)
|
|
.border_color(ACCENT)
|
|
.border_radius(8.0)
|
|
});
|
|
|
|
Container::new(card).style(|s| {
|
|
s.position(Position::Absolute)
|
|
.inset(0.0)
|
|
.items_start()
|
|
.justify_center()
|
|
.background(Color::from_rgba8(5, 7, 11, 220))
|
|
.z_index(50)
|
|
})
|
|
}
|
|
|
|
fn handle_key(
|
|
model: RwSignal<ShellModel>,
|
|
query: RwSignal<String>,
|
|
KeyboardEvent {
|
|
code,
|
|
key,
|
|
modifiers,
|
|
..
|
|
}: &KeyboardEvent,
|
|
) -> EventPropagation {
|
|
if *code == Code::KeyK && modifiers.contains(OS_MOD) {
|
|
query.set(String::new());
|
|
dispatch(model, ShellAction::OpenCommandPalette);
|
|
return EventPropagation::Stop;
|
|
}
|
|
|
|
if model.with(|model| model.command_palette().is_open()) {
|
|
if *key == Key::Named(NamedKey::Escape) {
|
|
query.set(String::new());
|
|
dispatch(model, ShellAction::CloseCommandPalette);
|
|
return EventPropagation::Stop;
|
|
}
|
|
return EventPropagation::Continue;
|
|
}
|
|
|
|
let action = match key {
|
|
Key::Named(NamedKey::ArrowLeft) => Some(ShellAction::MoveFocus(FocusDirection::Left)),
|
|
Key::Named(NamedKey::ArrowRight) => Some(ShellAction::MoveFocus(FocusDirection::Right)),
|
|
Key::Named(NamedKey::ArrowUp) => Some(ShellAction::MoveFocus(FocusDirection::Up)),
|
|
Key::Named(NamedKey::ArrowDown) => Some(ShellAction::MoveFocus(FocusDirection::Down)),
|
|
Key::Character(character) if modifiers.is_empty() => match character.as_str() {
|
|
"h" => Some(ShellAction::MoveFocus(FocusDirection::Left)),
|
|
"l" => Some(ShellAction::MoveFocus(FocusDirection::Right)),
|
|
"k" => Some(ShellAction::MoveFocus(FocusDirection::Up)),
|
|
"j" => Some(ShellAction::MoveFocus(FocusDirection::Down)),
|
|
"1" => Some(ShellAction::SelectPane(PaneId::CodexRuntime)),
|
|
"2" => Some(ShellAction::SelectPane(PaneId::ClaudeUi)),
|
|
"3" => Some(ShellAction::SelectPane(PaneId::PiDocs)),
|
|
"4" => Some(ShellAction::SelectPane(PaneId::Architecture)),
|
|
"5" => Some(ShellAction::SelectPane(PaneId::AcpPreview)),
|
|
"6" => Some(ShellAction::SelectPane(PaneId::RuntimeReview)),
|
|
_ => None,
|
|
},
|
|
_ => None,
|
|
};
|
|
|
|
if let Some(action) = action {
|
|
dispatch(model, action);
|
|
EventPropagation::Stop
|
|
} else {
|
|
EventPropagation::Continue
|
|
}
|
|
}
|
|
|
|
fn app_view() -> impl IntoView {
|
|
let model = RwSignal::new(ShellModel::with_all_panels_attached());
|
|
let query = RwSignal::new(String::new());
|
|
let timer_pulse = RwSignal::new(());
|
|
|
|
Effect::new(move |_| {
|
|
timer_pulse.track();
|
|
exec_after(Duration::from_millis(650), move |_| {
|
|
dispatch(model, ShellAction::SyntheticStreamTick);
|
|
timer_pulse.set(());
|
|
});
|
|
});
|
|
|
|
Effect::new(move |_| {
|
|
let query = query.get();
|
|
model.update(|model| {
|
|
if model.command_palette().is_open() && model.command_palette().query() != query {
|
|
model.dispatch(ShellAction::SetCommandPaletteQuery(query));
|
|
}
|
|
});
|
|
});
|
|
|
|
let top_row = Stack::horizontal((
|
|
pane_card(PaneId::CodexRuntime, model),
|
|
pane_card(PaneId::ClaudeUi, model),
|
|
pane_card(PaneId::PiDocs, model),
|
|
))
|
|
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0).width_full());
|
|
let bottom_row = Stack::horizontal((
|
|
pane_card(PaneId::Architecture, model),
|
|
pane_card(PaneId::AcpPreview, model),
|
|
pane_card(PaneId::RuntimeReview, model),
|
|
))
|
|
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0).width_full());
|
|
let grid = Stack::vertical((top_row, bottom_row)).style(|s| {
|
|
s.flex_basis(0)
|
|
.flex_grow(1.0)
|
|
.min_width(0.0)
|
|
.min_height(0.0)
|
|
.padding(4.0)
|
|
});
|
|
|
|
let header = Stack::horizontal((
|
|
Label::new("Lumbridge").style(|s| s.font_size(18.0).color(TEXT)),
|
|
Label::new("Lumbridge Code / main")
|
|
.style(|s| s.color(MUTED).margin_left(12.0).flex_grow(1.0)),
|
|
Label::new("metal · runtime online")
|
|
.style(|s| s.font_size(10.0).color(SUCCESS).margin_right(18.0)),
|
|
Label::new("Ctrl/⌘ K · Commands")
|
|
.style(|s| s.color(ACCENT).keyboard_navigable())
|
|
.on_event_stop(listener::Click, move |_, _| {
|
|
query.set(String::new());
|
|
dispatch(model, ShellAction::OpenCommandPalette);
|
|
}),
|
|
))
|
|
.style(|s| {
|
|
s.height(48.0)
|
|
.padding_horiz(14.0)
|
|
.items_center()
|
|
.background(PANEL_ALT)
|
|
.border_bottom(1.0)
|
|
.border_color(BORDER_QUIET)
|
|
});
|
|
|
|
let body = Stack::horizontal((
|
|
sidebar(),
|
|
Stack::vertical((tabs(), grid)).style(|s| {
|
|
s.flex_basis(0)
|
|
.flex_grow(1.0)
|
|
.min_width(0.0)
|
|
.min_height(0.0)
|
|
}),
|
|
))
|
|
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0));
|
|
|
|
let footer = Stack::horizontal((
|
|
Label::derived(move || {
|
|
model.with(|model| {
|
|
let counters = model.counters();
|
|
format!(
|
|
"rev {} · {} focus moves · {} surface updates · {} lines",
|
|
model.revision(),
|
|
counters.focus_moves,
|
|
counters.surface_updates,
|
|
counters.terminal_lines_appended
|
|
)
|
|
})
|
|
})
|
|
.style(|s| s.flex_grow(1.0)),
|
|
Label::new(FOOTER_FIXTURE_NOTICE).style(|s| s.flex_grow(1.0)),
|
|
))
|
|
.style(|s| {
|
|
s.height(32.0)
|
|
.padding_horiz(10.0)
|
|
.items_center()
|
|
.font_size(10.0)
|
|
.color(MUTED)
|
|
.background(PANEL_ALT)
|
|
.border_top(1.0)
|
|
.border_color(BORDER_QUIET)
|
|
});
|
|
|
|
let palette = dyn_container(
|
|
move || model.with(|model| model.command_palette().is_open()),
|
|
move |open| {
|
|
if open {
|
|
command_palette(query).into_any()
|
|
} else {
|
|
Empty::new().into_any()
|
|
}
|
|
},
|
|
);
|
|
|
|
let root = Stack::vertical((header, body, footer, palette))
|
|
.style(|s| {
|
|
s.position(Position::Relative)
|
|
.width_full()
|
|
.height_full()
|
|
.background(BG)
|
|
.color(TEXT)
|
|
.keyboard_navigable()
|
|
})
|
|
.on_event(listener::KeyDown, move |_, event| {
|
|
handle_key(model, query, event)
|
|
})
|
|
.window_title(|| "Lumbridge · Floem workspace".to_owned());
|
|
|
|
let root_id = root.id();
|
|
exec_after(Duration::from_millis(1), move |_| root_id.request_focus());
|
|
root
|
|
}
|
|
|
|
fn main() {
|
|
Application::new()
|
|
.window(
|
|
|_| app_view(),
|
|
Some(
|
|
WindowConfig::default()
|
|
.size(Size::new(1500.0, 960.0))
|
|
.min_size(Size::new(900.0, 600.0))
|
|
.title("Lumbridge · Floem workspace"),
|
|
),
|
|
)
|
|
.run();
|
|
}
|