Removes the retained-worktree store end to end: the daemon's state-dir store, GET/DELETE /api/worktrees, `maestro worktrees list|reclaim`, the TUI overlay behind `w`, and the web `#/worktrees` view and header link. The kill guard stays. A forced kill and a dirty natural exit now leave the worktree on disk untouched instead of parking it in a store, so removing one is an ordinary `git worktree remove`. A pristine tree is still removed on exit. DELETE /api/sessions/:id always answers 204. The header's debug button also goes; Ctrl+Shift+D and `?hud` still toggle the HUD. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
308 lines
11 KiB
Rust
308 lines
11 KiB
Rust
//! `TuiConfig` — runtime configuration for the TUI, built from the on-disk
|
|
//! [`maestro_config::TuiSection`].
|
|
//!
|
|
//! The TUI ships with a default keymap that covers every action; entries in
|
|
//! `[tui.keymap.normal]` / `[tui.keymap.insert]` are applied **over** those
|
|
//! defaults. The shipped defaults are not contractual — they will be tuned
|
|
//! during dogfooding; users override what they don't like.
|
|
|
|
use std::time::Duration;
|
|
|
|
use maestro_config::{TuiSection, TuiTheme};
|
|
|
|
use crate::input::InputKey;
|
|
use crate::keymap::{Keymap, KeymapError, Mode};
|
|
use crate::palette::Palette;
|
|
|
|
#[derive(Debug)]
|
|
pub struct TuiConfig {
|
|
pub palette: Palette,
|
|
pub leader: InputKey,
|
|
pub chord_timeout: Duration,
|
|
pub keymap: Keymap,
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum TuiConfigError {
|
|
#[error("invalid leader key {0:?}")]
|
|
BadLeader(String),
|
|
#[error("invalid keymap entry [{mode:?}] {input:?} = {action:?}: {source}")]
|
|
BadKeymap {
|
|
mode: Mode,
|
|
input: String,
|
|
action: String,
|
|
source: KeymapError,
|
|
},
|
|
}
|
|
|
|
/// Default Normal-mode bindings. Tunable during dogfooding — not contract.
|
|
/// Every variant of [`crate::action::Action`] reachable in v1 is covered.
|
|
const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|
// Mode
|
|
("i", "EnterInsert"),
|
|
// Sidebar navigation
|
|
("j", "SidebarNext"),
|
|
("k", "SidebarPrev"),
|
|
("down", "SidebarNext"),
|
|
("up", "SidebarPrev"),
|
|
("c", "ToggleSidebarGroup"),
|
|
// Catalog actions
|
|
("enter", "AttachHighlighted"),
|
|
("s", "NewDraftPane"),
|
|
// Fast-forward spawn (#67): the draft-pane key's neighbour does the same
|
|
// thing with every default resolved and no form.
|
|
("S", "FastForwardSpawn"),
|
|
("x", "KillHighlighted"),
|
|
("r", "RenameHighlighted"),
|
|
// Focus
|
|
("ctrl-w h", "FocusSidebar"),
|
|
// Session scrollback
|
|
("pageup", "ScrollPanePageUp"),
|
|
("pagedown", "ScrollPanePageDown"),
|
|
// Chrome
|
|
("ctrl-w b", "ToggleSidebar"),
|
|
("?", "ShowHelp"),
|
|
("ctrl-w q", "Quit"),
|
|
// Mouse
|
|
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
|
];
|
|
|
|
/// Default Insert-mode bindings. Empty by default — the only Insert binding
|
|
/// is the leader, which is inserted dynamically by [`TuiConfig::load`] so
|
|
/// rebinding the leader rebinds its Insert escape automatically.
|
|
const DEFAULT_INSERT: &[(&str, &str)] = &[];
|
|
|
|
impl TuiConfig {
|
|
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
|
|
/// field of the loaded `MaestroConfigFile`). The shipped defaults are
|
|
/// applied first, then user overrides on top.
|
|
pub fn load(section: &TuiSection) -> Result<Self, TuiConfigError> {
|
|
let palette = match section.theme.unwrap_or(TuiTheme::Dark) {
|
|
TuiTheme::Dark => Palette::DARK,
|
|
TuiTheme::Light => Palette::LIGHT,
|
|
TuiTheme::Ansi => Palette::ANSI,
|
|
TuiTheme::SolarizedDark => Palette::SOLARIZED_DARK,
|
|
TuiTheme::SolarizedLight => Palette::SOLARIZED_LIGHT,
|
|
TuiTheme::Catppuccin => Palette::CATPPUCCIN,
|
|
TuiTheme::GithubDark => Palette::GITHUB_DARK,
|
|
TuiTheme::GithubLight => Palette::GITHUB_LIGHT,
|
|
TuiTheme::TokyoNight => Palette::TOKYO_NIGHT,
|
|
TuiTheme::Gruvbox => Palette::GRUVBOX,
|
|
TuiTheme::Nord => Palette::NORD,
|
|
TuiTheme::Dracula => Palette::DRACULA,
|
|
};
|
|
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
|
|
let leader = InputKey::parse(leader_str)
|
|
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
|
|
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
|
|
|
|
let mut keymap = Keymap::default();
|
|
|
|
for (input, action) in DEFAULT_NORMAL {
|
|
keymap.set(Mode::Normal, input, action).map_err(|source| {
|
|
TuiConfigError::BadKeymap {
|
|
mode: Mode::Normal,
|
|
input: (*input).to_string(),
|
|
action: (*action).to_string(),
|
|
source,
|
|
}
|
|
})?;
|
|
}
|
|
for (input, action) in DEFAULT_INSERT {
|
|
keymap.set(Mode::Insert, input, action).map_err(|source| {
|
|
TuiConfigError::BadKeymap {
|
|
mode: Mode::Insert,
|
|
input: (*input).to_string(),
|
|
action: (*action).to_string(),
|
|
source,
|
|
}
|
|
})?;
|
|
}
|
|
// Leader → LeaveInsert is built from the leader spec so rebinding
|
|
// leader automatically rebinds the Insert escape.
|
|
keymap
|
|
.set(Mode::Insert, &leader.canonical(), "LeaveInsert")
|
|
.expect("leader canonical form is valid by construction");
|
|
|
|
for (input, action) in §ion.keymap_normal {
|
|
keymap.set(Mode::Normal, input, action).map_err(|source| {
|
|
TuiConfigError::BadKeymap {
|
|
mode: Mode::Normal,
|
|
input: input.clone(),
|
|
action: action.clone(),
|
|
source,
|
|
}
|
|
})?;
|
|
}
|
|
for (input, action) in §ion.keymap_insert {
|
|
keymap.set(Mode::Insert, input, action).map_err(|source| {
|
|
TuiConfigError::BadKeymap {
|
|
mode: Mode::Insert,
|
|
input: input.clone(),
|
|
action: action.clone(),
|
|
source,
|
|
}
|
|
})?;
|
|
}
|
|
|
|
Ok(TuiConfig {
|
|
palette,
|
|
leader,
|
|
chord_timeout,
|
|
keymap,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::field_reassign_with_default)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::action::Action;
|
|
use maestro_config::{TuiSection, TuiTheme};
|
|
|
|
#[test]
|
|
fn absent_theme_selects_the_dark_default() {
|
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
|
assert_eq!(cfg.palette, Palette::DARK);
|
|
}
|
|
|
|
#[test]
|
|
fn named_themes_select_the_matching_complete_palette() {
|
|
for (theme, expected) in [
|
|
(TuiTheme::Dark, Palette::DARK),
|
|
(TuiTheme::Light, Palette::LIGHT),
|
|
(TuiTheme::Ansi, Palette::ANSI),
|
|
(TuiTheme::SolarizedDark, Palette::SOLARIZED_DARK),
|
|
(TuiTheme::SolarizedLight, Palette::SOLARIZED_LIGHT),
|
|
(TuiTheme::Catppuccin, Palette::CATPPUCCIN),
|
|
(TuiTheme::GithubDark, Palette::GITHUB_DARK),
|
|
(TuiTheme::GithubLight, Palette::GITHUB_LIGHT),
|
|
(TuiTheme::TokyoNight, Palette::TOKYO_NIGHT),
|
|
(TuiTheme::Gruvbox, Palette::GRUVBOX),
|
|
(TuiTheme::Nord, Palette::NORD),
|
|
(TuiTheme::Dracula, Palette::DRACULA),
|
|
] {
|
|
let section = TuiSection {
|
|
theme: Some(theme),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(TuiConfig::load(§ion).unwrap().palette, expected);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn defaults_cover_core_actions() {
|
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
|
assert_eq!(
|
|
cfg.keymap.resolve(Mode::Normal, "i"),
|
|
Some(Action::EnterInsert),
|
|
);
|
|
assert_eq!(
|
|
cfg.keymap.resolve(Mode::Normal, "enter"),
|
|
Some(Action::AttachHighlighted),
|
|
);
|
|
assert_eq!(
|
|
cfg.keymap.resolve(Mode::Normal, "s"),
|
|
Some(Action::NewDraftPane),
|
|
);
|
|
assert_eq!(
|
|
cfg.keymap.resolve(Mode::Normal, "S"),
|
|
Some(Action::FastForwardSpawn),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn default_leader_is_ctrl_a_and_escapes_insert() {
|
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
|
assert_eq!(cfg.leader, InputKey::ctrl_char('a'));
|
|
assert_eq!(
|
|
cfg.keymap.resolve(Mode::Insert, "ctrl-a"),
|
|
Some(Action::LeaveInsert),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rebinding_leader_moves_insert_escape() {
|
|
let mut section = TuiSection::default();
|
|
section.leader = Some("ctrl-b".into());
|
|
let cfg = TuiConfig::load(§ion).unwrap();
|
|
assert_eq!(cfg.leader, InputKey::ctrl_char('b'));
|
|
assert_eq!(
|
|
cfg.keymap.resolve(Mode::Insert, "ctrl-b"),
|
|
Some(Action::LeaveInsert),
|
|
);
|
|
// The old leader's binding is gone (no Insert default for it).
|
|
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn user_normal_override_replaces_default() {
|
|
let mut section = TuiSection::default();
|
|
section.keymap_normal.insert("i".into(), "ShowHelp".into());
|
|
let cfg = TuiConfig::load(§ion).unwrap();
|
|
assert_eq!(
|
|
cfg.keymap.resolve(Mode::Normal, "i"),
|
|
Some(Action::ShowHelp),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn user_unbind_via_noop() {
|
|
let mut section = TuiSection::default();
|
|
section.keymap_normal.insert("i".into(), "Noop".into());
|
|
let cfg = TuiConfig::load(§ion).unwrap();
|
|
assert_eq!(cfg.keymap.resolve(Mode::Normal, "i"), Some(Action::Noop));
|
|
}
|
|
|
|
#[test]
|
|
fn bad_leader_surfaces_diagnostic() {
|
|
let mut section = TuiSection::default();
|
|
section.leader = Some("ctrl-glorb".into());
|
|
let err = TuiConfig::load(§ion).unwrap_err();
|
|
assert!(
|
|
format!("{err}").contains("ctrl-glorb"),
|
|
"diagnostic missing offending leader: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bad_user_binding_surfaces_diagnostic() {
|
|
let mut section = TuiSection::default();
|
|
section
|
|
.keymap_normal
|
|
.insert("ctrl-glorb".into(), "EnterInsert".into());
|
|
let err = TuiConfig::load(§ion).unwrap_err();
|
|
let s = format!("{err}");
|
|
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
|
}
|
|
|
|
/// Regression for #70: the previous canonical form of a real Shift+S
|
|
/// keypress was `"shift-S"`, which the default `"S"` binding never
|
|
/// matched. Drive the actual crossterm event, not the binding string,
|
|
/// so the dead-key bug can't come back unnoticed.
|
|
#[test]
|
|
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
|
|
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
|
|
|
|
use crate::input::{canonicalise, Region};
|
|
|
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
|
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
|
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
|
|
|
|
assert_eq!(
|
|
cfg.keymap.resolve(Mode::Normal, &key.canonical()),
|
|
Some(Action::FastForwardSpawn),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn custom_chord_timeout_is_applied() {
|
|
let mut section = TuiSection::default();
|
|
section.chord_timeout_ms = Some(250);
|
|
let cfg = TuiConfig::load(§ion).unwrap();
|
|
assert_eq!(cfg.chord_timeout, Duration::from_millis(250));
|
|
}
|
|
}
|