Add layered settings, and fix a migration mechanism that silently lied
Two things, because the second could not be built on the first. The schema stamp was part of the same execute_batch as the CREATE TABLE IF NOT EXISTS statements, and it wrote unconditionally. Opening an older file therefore added no columns but flipped the version forward anyway; opening a *newer* file stamped it back down and then wrote rows the newer build could not read. Both produced a database whose recorded version was a lie, and every future schema change would have inherited it. Now the version is read before anything is applied, migrations are ordered and forward-only inside one transaction, a newer file is refused with SchemaTooNew rather than downgraded, and a supported version raised without a step to reach it fails at the first open instead of claiming success. Tested by stamping a file at version 99 and asserting both the refusal and that the stamp is left untouched. lumbridge-settings resolves compiled default -> settings.toml -> environment. The environment sits above the file deliberately: decision 0016 calls LUMBRIDGE_CLAUDE_OAUTH=0 "one switch off", and a switch a config file can silently re-enable is not a switch. A pinned value renders disabled and names the variable, rather than accepting an edit that would do nothing. Every field carries a WriteAuthority. Routing all writes through Configure is the obvious design and would hand a layout-only agent the program every future pane launches — the guarantee decision 0006 exists to make. Anything naming a program, path or destination is Human-only, asserted by a test that reads the path rather than trusting the author. Four paths are permanently not settings, with the reason recorded beside each and a test asserting their absence: the usage endpoint URL, the credentials path, the client identity, and the shell program. A configuration file that can redirect where an access token is sent is a credential exfiltration path with a friendly name. Environment access is a trait rather than std::env, because the workspace forbids unsafe, set_var is unsafe in Rust 2024, and the layering rule has to be testable without mutating the process running the test. Verified live with LUMBRIDGE_CLAUDE_OAUTH=0: the account-endpoint row reads off, greyed, "pinned by LUMBRIDGE_CLAUDE_OAUTH". The Advanced page names every file, endpoint and child process Lumbridge touches and states that nothing is sent anywhere else — as a fact, not as a toggle nobody can flip. File loading, comment-preserving writes and editable controls are not in this pass; 0022 records why that order is the honest one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
72887cb4ab
commit
e5d7a3efd5
@@ -30,9 +30,9 @@ use gpui::KeyBinding;
|
||||
|
||||
use crate::{
|
||||
AddPanel, DetachSelectedPanel, FocusDown, FocusLeft, FocusRight, FocusSidebar, FocusUp,
|
||||
JumpToAttention, OpenPalette, PasteIntoPane, RestartPane, SelectPane1, SelectPane2,
|
||||
SelectPane3, SelectPane4, SelectPane5, SelectPane6, TerminalNarrower, TerminalShorter,
|
||||
TerminalTaller, TerminalWider, TerminatePane, ToggleSidebar,
|
||||
JumpToAttention, OpenPalette, OpenSettings, PasteIntoPane, RestartPane, SelectPane1,
|
||||
SelectPane2, SelectPane3, SelectPane4, SelectPane5, SelectPane6, TerminalNarrower,
|
||||
TerminalShorter, TerminalTaller, TerminalWider, TerminatePane, ToggleSidebar,
|
||||
};
|
||||
|
||||
/// The context every binding is scoped to.
|
||||
@@ -68,6 +68,7 @@ pub(crate) const BINDINGS: &[&str] = &[
|
||||
"secondary-alt-b",
|
||||
"secondary-alt-s",
|
||||
"secondary-alt-a",
|
||||
"secondary-alt-,",
|
||||
];
|
||||
|
||||
/// Builds the bindings in the same order as [`BINDINGS`].
|
||||
@@ -100,6 +101,7 @@ pub(crate) fn bindings() -> Vec<KeyBinding> {
|
||||
KeyBinding::new(BINDINGS[20], ToggleSidebar, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[21], FocusSidebar, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[22], JumpToAttention, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[23], OpenSettings, Some(CONTEXT)),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
+110
-1
@@ -1,6 +1,7 @@
|
||||
mod attention;
|
||||
mod keymap;
|
||||
mod panel_registry;
|
||||
mod settings_view;
|
||||
mod sidebar;
|
||||
mod theme;
|
||||
mod usage_feed;
|
||||
@@ -31,6 +32,7 @@ use lumbridge_ui_fixture::{
|
||||
|
||||
use attention::{Attention, AttentionKind, AttentionSignal, AttentionSource};
|
||||
use lumbridge_harness::MonotonicWallClock;
|
||||
use lumbridge_settings::{Page, ProcessEnv, Settings, SettingsContent};
|
||||
use panel_registry::{PanelId, PanelKind, PanelRegistry, SeedPane};
|
||||
use sidebar::model::{
|
||||
AttentionEntry, HostEntry, Indicator, PaneActivity, PaneEntry, QuotaEntry, SidebarInput,
|
||||
@@ -69,6 +71,7 @@ actions!(
|
||||
TerminatePane,
|
||||
PasteIntoPane,
|
||||
ToggleSidebar,
|
||||
OpenSettings,
|
||||
FocusSidebar,
|
||||
JumpToAttention,
|
||||
SelectPane1,
|
||||
@@ -112,6 +115,9 @@ struct LumbridgeShell {
|
||||
sidebar: SidebarState,
|
||||
sidebar_has_focus: bool,
|
||||
sidebar_dragging: bool,
|
||||
settings: Settings,
|
||||
/// The settings page on screen, if the pane is open.
|
||||
settings_page: Option<Page>,
|
||||
/// Stamps attention signals. Monotonic, so a signal cannot appear to have
|
||||
/// arrived before one recorded earlier.
|
||||
clock: MonotonicWallClock,
|
||||
@@ -749,6 +755,12 @@ impl LumbridgeShell {
|
||||
sidebar,
|
||||
sidebar_has_focus: false,
|
||||
sidebar_dragging: false,
|
||||
// No file is read yet: this resolves the compiled defaults against
|
||||
// the real environment, so the pane already tells the truth about
|
||||
// which switches are pinned. Loading and watching the file is the
|
||||
// next step.
|
||||
settings: Settings::resolve(&SettingsContent::default(), Vec::new(), &ProcessEnv),
|
||||
settings_page: None,
|
||||
clock: MonotonicWallClock::start(),
|
||||
root_focus,
|
||||
}
|
||||
@@ -1181,6 +1193,101 @@ impl LumbridgeShell {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Opens the settings pane, or closes it.
|
||||
fn open_settings(&mut self, _: &OpenSettings, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.settings_page = match self.settings_page {
|
||||
Some(_) => None,
|
||||
None => Some(Page::UsageAndQuota),
|
||||
};
|
||||
window.focus(&self.root_focus);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The settings pane: a page list beside the page.
|
||||
fn settings_pane(&self, page: Page, cx: &mut Context<Self>) -> gpui::AnyElement {
|
||||
let theme = self.theme.colors;
|
||||
div()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.bg(theme.scrim_wash)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.w(px(880.0))
|
||||
.h(px(560.0))
|
||||
.rounded(px(8.0))
|
||||
.overflow_hidden()
|
||||
.bg(theme.surface_overlay)
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.w(px(200.0))
|
||||
.flex_none()
|
||||
.bg(theme.chrome)
|
||||
.border_r_1()
|
||||
.border_color(theme.border_quiet)
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.py_3()
|
||||
.text_sm()
|
||||
.text_color(theme.text)
|
||||
.child("Settings"),
|
||||
)
|
||||
.children(Page::ALL.into_iter().map(|candidate| {
|
||||
div()
|
||||
.id(("settings-page", u64::from(candidate as u32)))
|
||||
.cursor_pointer()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_sm()
|
||||
.when(candidate == page, |view| {
|
||||
view.bg(theme.surface_active).text_color(theme.text)
|
||||
})
|
||||
.when(candidate != page, |view| view.text_color(theme.muted))
|
||||
.hover(|view| view.bg(theme.surface_raised))
|
||||
.on_click(cx.listener(move |shell, _, _, cx| {
|
||||
shell.settings_page = Some(candidate);
|
||||
cx.notify();
|
||||
}))
|
||||
.child(candidate.title())
|
||||
}))
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_xs()
|
||||
.text_color(theme.muted)
|
||||
.child("⌘⌥, closes"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.py_3()
|
||||
.text_sm()
|
||||
.text_color(theme.text)
|
||||
.child(page.title()),
|
||||
)
|
||||
.child(settings_view::page(page, &self.settings, theme)),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn toggle_sidebar(&mut self, _: &ToggleSidebar, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.sidebar.visible = !self.sidebar.visible;
|
||||
if !self.sidebar.visible {
|
||||
@@ -2707,6 +2814,7 @@ impl LumbridgeShell {
|
||||
.on_action(cx.listener(Self::toggle_sidebar))
|
||||
.on_action(cx.listener(Self::focus_sidebar))
|
||||
.on_action(cx.listener(Self::jump_to_attention))
|
||||
.on_action(cx.listener(Self::open_settings))
|
||||
.on_action(cx.listener(Self::terminal_taller))
|
||||
.on_action(cx.listener(Self::terminal_shorter))
|
||||
.on_action(cx.listener(Self::terminal_wider))
|
||||
@@ -2822,6 +2930,7 @@ impl LumbridgeShell {
|
||||
.when(self.add_panel_chooser_open, |view| {
|
||||
view.child(self.add_panel_chooser(cx))
|
||||
})
|
||||
.children(self.settings_page.map(|page| self.settings_pane(page, cx)))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -2978,7 +3087,7 @@ impl LumbridgeShell {
|
||||
}))
|
||||
.child(sidebar::view::row(
|
||||
&row,
|
||||
sidebar::view::RowStyle {
|
||||
&sidebar::view::RowStyle {
|
||||
theme,
|
||||
cursored,
|
||||
focused,
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//! The settings pane.
|
||||
//!
|
||||
//! Four pages that describe code which actually runs. Every row says three
|
||||
//! things a settings interface usually leaves out: where the value came from,
|
||||
//! when a change takes effect, and who is allowed to make it.
|
||||
//!
|
||||
//! A control whose value was pinned by the environment is drawn disabled with
|
||||
//! the variable named, rather than accepting an edit that would silently do
|
||||
//! nothing — decision 0016 calls that switch "one switch off", and an interface
|
||||
//! that lets a file or a click override it has quietly broken the promise.
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{div, px};
|
||||
use lumbridge_settings::{Page, SETTINGS, SettingItem, Settings, WriteAuthority};
|
||||
|
||||
use crate::theme::ThemeColors;
|
||||
|
||||
/// Everything one row needs to say.
|
||||
struct Row<'a> {
|
||||
item: &'a SettingItem,
|
||||
value: String,
|
||||
origin: String,
|
||||
editable: bool,
|
||||
}
|
||||
|
||||
/// One row: what it is, where it came from, when it applies.
|
||||
fn setting_row(row: &Row<'_>, theme: ThemeColors) -> gpui::AnyElement {
|
||||
let Row {
|
||||
item,
|
||||
value,
|
||||
origin,
|
||||
editable,
|
||||
} = row;
|
||||
let (label, detail, takes_effect, authority) = (
|
||||
item.label,
|
||||
item.detail,
|
||||
item.takes_effect.label(),
|
||||
item.authority,
|
||||
);
|
||||
let editable = *editable;
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(2.0))
|
||||
.px_3()
|
||||
.py_2()
|
||||
.border_b_1()
|
||||
.border_color(theme.border_quiet)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_sm()
|
||||
.text_color(if editable { theme.text } else { theme.muted })
|
||||
.child(label.to_owned()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.px(px(5.0))
|
||||
.rounded(px(3.0))
|
||||
.text_xs()
|
||||
.bg(theme.surface_raised)
|
||||
.text_color(if editable { theme.accent } else { theme.muted })
|
||||
.child(value.to_owned()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(theme.muted)
|
||||
.child(detail.to_owned()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.text_color(theme.muted)
|
||||
.child(origin.to_owned())
|
||||
.child("·")
|
||||
.child(takes_effect.to_owned())
|
||||
.when(authority == WriteAuthority::Human, |view| {
|
||||
view.child("·").child(
|
||||
div()
|
||||
.text_color(theme.attention)
|
||||
.child("a person must set this"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Renders one page.
|
||||
pub(crate) fn page(page: Page, settings: &Settings, theme: ThemeColors) -> gpui::AnyElement {
|
||||
let value_for = |path: &str| -> (String, String, bool) {
|
||||
match path {
|
||||
"appearance.theme" => (
|
||||
settings.theme.value.clone(),
|
||||
settings.theme.origin.label(),
|
||||
settings.theme.origin.is_editable(),
|
||||
),
|
||||
"appearance.accent" => (
|
||||
settings.accent.value.clone(),
|
||||
settings.accent.origin.label(),
|
||||
settings.accent.origin.is_editable(),
|
||||
),
|
||||
"usage.claude_account_endpoint" => (
|
||||
on_off(settings.claude_account_endpoint.value),
|
||||
settings.claude_account_endpoint.origin.label(),
|
||||
settings.claude_account_endpoint.origin.is_editable(),
|
||||
),
|
||||
"usage.claude_transcripts" => (
|
||||
on_off(settings.claude_transcripts.value),
|
||||
settings.claude_transcripts.origin.label(),
|
||||
settings.claude_transcripts.origin.is_editable(),
|
||||
),
|
||||
"usage.codex_app_server" => (
|
||||
on_off(settings.codex_app_server.value),
|
||||
settings.codex_app_server.origin.label(),
|
||||
settings.codex_app_server.origin.is_editable(),
|
||||
),
|
||||
"usage.claude_rate_limit_feed" => (
|
||||
settings
|
||||
.claude_rate_limit_feed
|
||||
.value
|
||||
.clone()
|
||||
.unwrap_or_else(|| "wherever the bridge was installed".to_owned()),
|
||||
settings.claude_rate_limit_feed.origin.label(),
|
||||
settings.claude_rate_limit_feed.origin.is_editable(),
|
||||
),
|
||||
"terminal.allow_osc52_clipboard" => (
|
||||
on_off(settings.allow_osc52_clipboard.value),
|
||||
settings.allow_osc52_clipboard.origin.label(),
|
||||
settings.allow_osc52_clipboard.origin.is_editable(),
|
||||
),
|
||||
_ => ("—".to_owned(), "unknown".to_owned(), false),
|
||||
}
|
||||
};
|
||||
|
||||
let rows: Vec<gpui::AnyElement> = SETTINGS
|
||||
.iter()
|
||||
.filter(|item| item.page == page)
|
||||
.map(|item| {
|
||||
let (value, origin, editable) = value_for(item.path);
|
||||
setting_row(
|
||||
&Row {
|
||||
item,
|
||||
value,
|
||||
origin,
|
||||
editable,
|
||||
},
|
||||
theme,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.children(rows)
|
||||
.when(page == Page::Advanced, |view| {
|
||||
view.child(what_lumbridge_reads(settings, theme))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn on_off(value: bool) -> String {
|
||||
if value { "on" } else { "off" }.to_owned()
|
||||
}
|
||||
|
||||
/// The Advanced page's disclosure.
|
||||
///
|
||||
/// Stated as fact rather than offered as a toggle. "No telemetry" is not a
|
||||
/// setting anyone can turn on, so presenting it as one would be theatre.
|
||||
fn what_lumbridge_reads(settings: &Settings, theme: ThemeColors) -> gpui::AnyElement {
|
||||
let reads = [
|
||||
"~/.claude/projects — session transcripts, four token counters per record",
|
||||
"the status-line feed — the two subscription windows, written by the installed bridge",
|
||||
"~/.claude/.credentials.json — the access token, only when the account endpoint is on",
|
||||
"GET api.anthropic.com/api/oauth/usage — your own account, identified as lumbridge",
|
||||
"codex app-server — launched as a child, asked only for its quota windows",
|
||||
];
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(2.0))
|
||||
.px_3()
|
||||
.py_2()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(theme.muted)
|
||||
.child("WHAT LUMBRIDGE READS"),
|
||||
)
|
||||
.children(reads.into_iter().map(|line| {
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(theme.muted)
|
||||
.child(line.to_owned())
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.mt_2()
|
||||
.text_xs()
|
||||
.text_color(theme.success)
|
||||
.child("Nothing is sent anywhere else. There is no telemetry to turn off."),
|
||||
)
|
||||
.when(!settings.unknown.is_empty(), |view| {
|
||||
view.child(
|
||||
div()
|
||||
.mt_2()
|
||||
.text_xs()
|
||||
.text_color(theme.attention)
|
||||
.child(format!(
|
||||
"{} key(s) in settings.toml were not understood by this build and were left alone: {}",
|
||||
settings.unknown.len(),
|
||||
settings
|
||||
.unknown
|
||||
.iter()
|
||||
.map(|key| key.path.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)),
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -94,7 +94,7 @@ fn quota_chip(headline: Option<&str>, critical: bool, theme: ThemeColors) -> gpu
|
||||
clippy::too_many_lines,
|
||||
reason = "one declarative element tree per row kind; splitting hides the shared anatomy"
|
||||
)]
|
||||
pub(crate) fn row(row: &SidebarRow, style: RowStyle) -> gpui::AnyElement {
|
||||
pub(crate) fn row(row: &SidebarRow, style: &RowStyle) -> gpui::AnyElement {
|
||||
let theme = style.theme;
|
||||
let selected = matches!(&row.body, RowBody::Pane(pane) if pane.selected);
|
||||
let indent = f32::from(row.depth) * 10.0;
|
||||
|
||||
@@ -25,10 +25,9 @@ pub(crate) struct ThemeColors {
|
||||
pub(crate) surface: Rgba,
|
||||
pub(crate) surface_raised: Rgba,
|
||||
pub(crate) surface_active: Rgba,
|
||||
/// Not yet painted anywhere. The command palette and the panel chooser
|
||||
/// still sit on `surface_raised`; moving them is a visible change, and the
|
||||
/// commit that introduced this engine is deliberately not one.
|
||||
#[expect(dead_code, reason = "the overlay surfaces move onto it separately")]
|
||||
/// The settings pane sits on this. The command palette and the panel
|
||||
/// chooser still use `surface_raised`; moving them is a visible change and
|
||||
/// belongs in a commit that says so.
|
||||
pub(crate) surface_overlay: Rgba,
|
||||
pub(crate) border: Rgba,
|
||||
pub(crate) border_quiet: Rgba,
|
||||
@@ -45,6 +44,9 @@ pub(crate) struct ThemeColors {
|
||||
/// The needs-input row tint, which arrives with the sidebar rework.
|
||||
#[expect(dead_code, reason = "the attention row is rebuilt with the sidebar")]
|
||||
pub(crate) attention_wash: Rgba,
|
||||
/// The dimming behind a modal. Translucent, so what it covers stays legible
|
||||
/// as context rather than disappearing.
|
||||
pub(crate) scrim_wash: Rgba,
|
||||
}
|
||||
|
||||
/// Widens an 8-bit channel into the 0..=1 float GPUI wants.
|
||||
@@ -79,6 +81,10 @@ impl From<&Palette> for ThemeColors {
|
||||
danger_container: rgba(palette.danger_container),
|
||||
attention: rgba(palette.attention),
|
||||
attention_wash: rgba(palette.attention_wash),
|
||||
scrim_wash: Rgba {
|
||||
a: 0.55,
|
||||
..rgba(palette.scrim)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user