feat: add live config reload
This commit is contained in:
parent
ef4182efd9
commit
3d1ef155bf
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Config can now be reloaded in the running app/server from the global menu or with `herdr server reload-config`, applying safe live settings without restarting the persistent server.
|
||||
|
||||
### Fixed
|
||||
- Persistent server startup now surfaces config diagnostics in attached clients instead of silently hiding parse or validation errors.
|
||||
|
||||
## [0.5.1] - 2026-04-25
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -14,6 +14,35 @@ herdr --default-config
|
|||
|
||||
if a config value is invalid, or two navigate actions use the same keybinding, herdr falls back to a safe default and shows a startup warning in the UI.
|
||||
|
||||
## live reload
|
||||
|
||||
After editing `config.toml`, reload the running app without restarting the persistent server:
|
||||
|
||||
```bash
|
||||
herdr server reload-config
|
||||
```
|
||||
|
||||
You can also use the global menu inside herdr and choose `reload config`.
|
||||
|
||||
Reload is server-owned. In persistent mode the CLI sends a request to the running server, and the server reads, parses, validates, and applies `config.toml`.
|
||||
|
||||
Reloadable now:
|
||||
- keybindings and prefix
|
||||
- theme, custom theme colors, and legacy `ui.accent`
|
||||
- `ui.confirm_close`
|
||||
- `ui.toast.delivery`
|
||||
- server-side `ui.sound` policy
|
||||
- `advanced.scrollback_limit_bytes` for panes created after reload
|
||||
- `ui.sidebar_width` as the default width
|
||||
|
||||
Startup-only or special-case:
|
||||
- `onboarding` does not reopen onboarding during reload
|
||||
- `advanced.allow_nested` is checked before launch and needs a restart
|
||||
- existing pane scrollback buffers are not resized during reload
|
||||
- already-attached thin clients may need to reconnect before client-local sound file/path changes are picked up
|
||||
|
||||
If the TOML cannot be read or parsed, reload applies nothing and keeps the current running state. If keybindings are invalid, herdr keeps the current keybindings while applying other valid reloadable settings where possible.
|
||||
|
||||
## onboarding
|
||||
|
||||
```toml
|
||||
|
|
@ -54,6 +83,7 @@ prefix = "ctrl+b"
|
|||
new_workspace = "n"
|
||||
rename_workspace = "shift+n"
|
||||
close_workspace = "shift+d"
|
||||
reload_config = "" # optional, unset by default
|
||||
new_tab = "c"
|
||||
split_vertical = "v"
|
||||
split_horizontal = "-"
|
||||
|
|
@ -80,6 +110,7 @@ focus_pane_right = "alt+l"
|
|||
| `rename_workspace` | `shift+n` | rename selected workspace |
|
||||
| `close_workspace` | `shift+d` | close selected workspace |
|
||||
| `detach` | unset | optional explicit detach shortcut in the persistent session |
|
||||
| `reload_config` | unset | reload `config.toml` in the running app/server |
|
||||
| `previous_workspace` | unset | switch to the previous workspace directly from terminal mode |
|
||||
| `next_workspace` | unset | switch to the next workspace directly from terminal mode |
|
||||
| `new_tab` | `c` | create a new tab |
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
|
|||
pub(crate) fn request_changes_ui(request: &Request) -> bool {
|
||||
matches!(
|
||||
&request.method,
|
||||
Method::WorkspaceCreate(_)
|
||||
Method::ServerReloadConfig(_)
|
||||
| Method::WorkspaceCreate(_)
|
||||
| Method::WorkspaceFocus(_)
|
||||
| Method::WorkspaceRename(_)
|
||||
| Method::WorkspaceClose(_)
|
||||
|
|
@ -317,6 +318,7 @@ fn api_method_name(method: &Method) -> &'static str {
|
|||
match method {
|
||||
Method::Ping(_) => "ping",
|
||||
Method::ServerStop(_) => "server.stop",
|
||||
Method::ServerReloadConfig(_) => "server.reload_config",
|
||||
Method::WorkspaceCreate(_) => "workspace.create",
|
||||
Method::WorkspaceList(_) => "workspace.list",
|
||||
Method::WorkspaceGet(_) => "workspace.get",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ pub enum Method {
|
|||
Ping(PingParams),
|
||||
#[serde(rename = "server.stop")]
|
||||
ServerStop(EmptyParams),
|
||||
#[serde(rename = "server.reload_config")]
|
||||
ServerReloadConfig(EmptyParams),
|
||||
#[serde(rename = "workspace.create")]
|
||||
WorkspaceCreate(WorkspaceCreateParams),
|
||||
#[serde(rename = "workspace.list")]
|
||||
|
|
@ -475,6 +477,10 @@ pub enum ResponseResult {
|
|||
target: IntegrationTarget,
|
||||
details: IntegrationUninstallResult,
|
||||
},
|
||||
ConfigReload {
|
||||
status: crate::config::ConfigReloadStatus,
|
||||
diagnostics: Vec<String>,
|
||||
},
|
||||
Ok {},
|
||||
}
|
||||
|
||||
|
|
@ -765,6 +771,19 @@ mod tests {
|
|||
assert_eq!(restored, request);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_round_trips_for_server_reload_config() {
|
||||
let request = Request {
|
||||
id: "req_reload".into(),
|
||||
method: Method::ServerReloadConfig(EmptyParams::default()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&request).unwrap();
|
||||
assert_eq!(json["method"], "server.reload_config");
|
||||
let restored: Request = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored, request);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_method_is_rejected() {
|
||||
let json = r#"{"id":"req_1","method":"nope","params":{}}"#;
|
||||
|
|
|
|||
|
|
@ -587,7 +587,7 @@ impl AppState {
|
|||
pane.seen = false;
|
||||
}
|
||||
|
||||
if self.sound.allows(change.known_agent) {
|
||||
if self.local_sound_playback && self.sound.allows(change.known_agent) {
|
||||
if let Some(sound) = notification_sound_for_state_change(
|
||||
is_active_tab,
|
||||
change.previous_state,
|
||||
|
|
|
|||
|
|
@ -282,6 +282,16 @@ impl App {
|
|||
result: ResponseResult::Ok {},
|
||||
}
|
||||
}
|
||||
Method::ServerReloadConfig(_) => {
|
||||
let report = self.reload_config();
|
||||
SuccessResponse {
|
||||
id: request.id,
|
||||
result: ResponseResult::ConfigReload {
|
||||
status: report.status,
|
||||
diagnostics: report.diagnostics,
|
||||
},
|
||||
}
|
||||
}
|
||||
Method::WorkspaceList(_) => SuccessResponse {
|
||||
id: request.id,
|
||||
result: ResponseResult::WorkspaceList {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use super::App;
|
||||
|
||||
impl App {
|
||||
pub(super) fn update_config_file<F>(&mut self, error_context: &str, update: F)
|
||||
pub(super) fn update_config_file<F>(&mut self, error_context: &str, update: F) -> bool
|
||||
where
|
||||
F: FnOnce(&str) -> String,
|
||||
{
|
||||
|
|
@ -13,7 +13,7 @@ impl App {
|
|||
Some(format!("failed to save {error_context}: {err}"));
|
||||
self.config_diagnostic_deadline =
|
||||
Some(std::time::Instant::now() + std::time::Duration::from_secs(5));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -24,7 +24,10 @@ impl App {
|
|||
self.state.config_diagnostic = Some(format!("failed to save {error_context}: {err}"));
|
||||
self.config_diagnostic_deadline =
|
||||
Some(std::time::Instant::now() + std::time::Duration::from_secs(5));
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn mark_onboarding_complete(&mut self) {
|
||||
|
|
@ -34,15 +37,19 @@ impl App {
|
|||
}
|
||||
|
||||
pub(super) fn save_theme(&mut self, name: &str) {
|
||||
self.update_config_file("theme", |content| {
|
||||
if self.update_config_file("theme", |content| {
|
||||
crate::config::upsert_section_value(content, "theme", "name", &format!("\"{name}\""))
|
||||
});
|
||||
}) {
|
||||
self.apply_config_from_disk(false);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn save_sound(&mut self, enabled: bool) {
|
||||
self.update_config_file("sound setting", |content| {
|
||||
if self.update_config_file("sound setting", |content| {
|
||||
crate::config::upsert_section_bool(content, "ui.sound", "enabled", enabled)
|
||||
});
|
||||
}) {
|
||||
self.apply_config_from_disk(false);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn save_toast_delivery(&mut self, delivery: crate::config::ToastDelivery) {
|
||||
|
|
@ -51,10 +58,12 @@ impl App {
|
|||
crate::config::ToastDelivery::Herdr => "\"herdr\"",
|
||||
crate::config::ToastDelivery::Terminal => "\"terminal\"",
|
||||
};
|
||||
self.update_config_file("toast setting", |content| {
|
||||
if self.update_config_file("toast setting", |content| {
|
||||
let content =
|
||||
crate::config::upsert_section_value(content, "ui.toast", "delivery", value);
|
||||
crate::config::remove_section_key(&content, "ui.toast", "enabled")
|
||||
});
|
||||
}) {
|
||||
self.apply_config_from_disk(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ pub(crate) enum GlobalMenuAction {
|
|||
Quit,
|
||||
WhatsNew,
|
||||
Keybinds,
|
||||
ReloadKeybinds,
|
||||
ReloadConfig,
|
||||
Settings,
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ pub(super) fn global_menu_actions(state: &AppState) -> Vec<GlobalMenuAction> {
|
|||
let mut actions = vec![
|
||||
GlobalMenuAction::Settings,
|
||||
GlobalMenuAction::Keybinds,
|
||||
GlobalMenuAction::ReloadKeybinds,
|
||||
GlobalMenuAction::ReloadConfig,
|
||||
];
|
||||
if state.update_available.is_some() || state.latest_release_notes_available {
|
||||
actions.push(GlobalMenuAction::WhatsNew);
|
||||
|
|
@ -126,8 +126,8 @@ pub(super) fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuA
|
|||
}
|
||||
GlobalMenuAction::WhatsNew => open_update_release_notes(state),
|
||||
GlobalMenuAction::Keybinds => open_keybind_help(state),
|
||||
GlobalMenuAction::ReloadKeybinds => {
|
||||
state.request_reload_keybinds = true;
|
||||
GlobalMenuAction::ReloadConfig => {
|
||||
state.request_reload_config = true;
|
||||
leave_modal(state);
|
||||
}
|
||||
GlobalMenuAction::Settings => super::settings::open_settings(state),
|
||||
|
|
|
|||
|
|
@ -350,6 +350,7 @@ pub(crate) enum NavigateAction {
|
|||
Fullscreen,
|
||||
EnterResizeMode,
|
||||
ToggleSidebar,
|
||||
ReloadConfig,
|
||||
Detach,
|
||||
}
|
||||
|
||||
|
|
@ -421,6 +422,12 @@ fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateA
|
|||
if key_matches(key, kb.toggle_sidebar.0, kb.toggle_sidebar.1) {
|
||||
return Some(NavigateAction::ToggleSidebar);
|
||||
}
|
||||
if kb
|
||||
.reload_config
|
||||
.is_some_and(|(code, mods)| key_matches(key, code, mods))
|
||||
{
|
||||
return Some(NavigateAction::ReloadConfig);
|
||||
}
|
||||
if kb
|
||||
.detach
|
||||
.is_some_and(|(code, mods)| key_matches(key, code, mods))
|
||||
|
|
@ -498,6 +505,10 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi
|
|||
state.sidebar_collapsed = !state.sidebar_collapsed;
|
||||
leave_navigate_mode(state);
|
||||
}
|
||||
NavigateAction::ReloadConfig => {
|
||||
state.request_reload_config = true;
|
||||
leave_navigate_mode(state);
|
||||
}
|
||||
NavigateAction::Detach => {
|
||||
state.detach_requested = true;
|
||||
leave_navigate_mode(state);
|
||||
|
|
@ -582,6 +593,21 @@ mod tests {
|
|||
assert_eq!(state.mode, Mode::Resize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_reload_config_key_requests_reload_and_exits_navigate() {
|
||||
let mut state = state_with_workspaces(&["test"]);
|
||||
state.keybinds.reload_config = Some((KeyCode::Char('g'), KeyModifiers::empty()));
|
||||
state.keybinds.reload_config_label = Some("g".into());
|
||||
|
||||
handle_navigate_key(
|
||||
&mut state,
|
||||
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
|
||||
);
|
||||
|
||||
assert!(state.request_reload_config);
|
||||
assert_eq!(state.mode, Mode::Terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement_action_stays_in_navigate_mode() {
|
||||
let mut state = state_with_workspaces(&["a", "b"]);
|
||||
|
|
|
|||
|
|
@ -125,7 +125,6 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti
|
|||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
let enabled = state.settings.list.selected == 0;
|
||||
state.sound.enabled = enabled;
|
||||
return Some(SettingsAction::SaveSound(enabled));
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
|
||||
|
|
@ -146,8 +145,6 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti
|
|||
KeyCode::Down | KeyCode::Char('j') => state.settings.list.move_next(3),
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
let delivery = toast_delivery_for_index(state.settings.list.selected);
|
||||
state.toast_config.delivery = delivery;
|
||||
state.toast = None;
|
||||
return Some(SettingsAction::SaveToastDelivery(delivery));
|
||||
}
|
||||
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
|
||||
|
|
@ -271,13 +268,10 @@ impl AppState {
|
|||
}
|
||||
SettingsSection::Sound => {
|
||||
let enabled = idx == 0;
|
||||
self.sound.enabled = enabled;
|
||||
Some(SettingsAction::SaveSound(enabled))
|
||||
}
|
||||
SettingsSection::Toast => {
|
||||
let delivery = toast_delivery_for_index(idx);
|
||||
self.toast_config.delivery = delivery;
|
||||
self.toast = None;
|
||||
Some(SettingsAction::SaveToastDelivery(delivery))
|
||||
}
|
||||
};
|
||||
|
|
@ -362,7 +356,7 @@ mod tests {
|
|||
);
|
||||
|
||||
assert_eq!(action, Some(SettingsAction::SaveSound(true)));
|
||||
assert!(state.sound.enabled);
|
||||
assert!(!state.sound.enabled);
|
||||
assert_eq!(state.mode, Mode::Settings);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ impl AppState {
|
|||
}
|
||||
|
||||
pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> {
|
||||
let mut labels = vec!["settings", "keybinds", "reload keybinds"];
|
||||
let mut labels = vec!["settings", "keybinds", "reload config"];
|
||||
if self.update_available.is_some() {
|
||||
labels.push("update ready");
|
||||
} else if self.latest_release_notes_available {
|
||||
|
|
@ -530,7 +530,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_reload_keybinds_menu_item_requests_reload() {
|
||||
fn clicking_reload_config_menu_item_requests_reload() {
|
||||
let mut app = app_for_mouse_test();
|
||||
let launcher = app.state.global_launcher_rect();
|
||||
app.handle_mouse(mouse(
|
||||
|
|
@ -546,7 +546,7 @@ mod tests {
|
|||
menu.y + 3,
|
||||
));
|
||||
|
||||
assert!(app.state.request_reload_keybinds);
|
||||
assert!(app.state.request_reload_config);
|
||||
assert_eq!(app.state.mode, Mode::Navigate);
|
||||
}
|
||||
|
||||
|
|
@ -568,7 +568,7 @@ mod tests {
|
|||
vec![
|
||||
"settings",
|
||||
"keybinds",
|
||||
"reload keybinds",
|
||||
"reload config",
|
||||
"update ready",
|
||||
"quit"
|
||||
]
|
||||
|
|
@ -590,7 +590,7 @@ mod tests {
|
|||
|
||||
assert_eq!(
|
||||
app.state.global_menu_labels(),
|
||||
vec!["settings", "keybinds", "reload keybinds", "detach"]
|
||||
vec!["settings", "keybinds", "reload config", "detach"]
|
||||
);
|
||||
|
||||
let menu = app.state.global_menu_rect();
|
||||
|
|
@ -615,7 +615,7 @@ mod tests {
|
|||
vec![
|
||||
"settings",
|
||||
"keybinds",
|
||||
"reload keybinds",
|
||||
"reload config",
|
||||
"what's new",
|
||||
"quit"
|
||||
]
|
||||
|
|
|
|||
205
src/app/mod.rs
205
src/app/mod.rs
|
|
@ -247,7 +247,7 @@ impl App {
|
|||
detach_requested: false,
|
||||
request_new_workspace: false,
|
||||
request_new_tab: false,
|
||||
request_reload_keybinds: false,
|
||||
request_reload_config: false,
|
||||
request_clipboard_write: None,
|
||||
creating_new_tab: false,
|
||||
requested_new_tab_name: None,
|
||||
|
|
@ -299,6 +299,7 @@ impl App {
|
|||
pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes,
|
||||
accent: crate::config::parse_color(&config.ui.accent),
|
||||
sound: config.ui.sound.clone(),
|
||||
local_sound_playback: true,
|
||||
toast_config: config.ui.toast.clone(),
|
||||
keybinds: config.keybinds(),
|
||||
spinner_tick: 0,
|
||||
|
|
@ -415,9 +416,9 @@ impl App {
|
|||
needs_render = true;
|
||||
}
|
||||
|
||||
if self.state.request_reload_keybinds {
|
||||
self.state.request_reload_keybinds = false;
|
||||
self.reload_keybinds();
|
||||
if self.state.request_reload_config {
|
||||
self.state.request_reload_config = false;
|
||||
self.reload_config();
|
||||
needs_render = true;
|
||||
}
|
||||
|
||||
|
|
@ -531,32 +532,105 @@ impl App {
|
|||
crate::app::input::open_settings(&mut self.state);
|
||||
}
|
||||
|
||||
pub(crate) fn reload_keybinds(&mut self) {
|
||||
pub(crate) fn reload_config(&mut self) -> crate::config::ConfigReloadReport {
|
||||
self.apply_config_from_disk(true)
|
||||
}
|
||||
|
||||
pub(crate) fn apply_config_from_disk(
|
||||
&mut self,
|
||||
notify_success: bool,
|
||||
) -> crate::config::ConfigReloadReport {
|
||||
let previous_toast = self.state.toast.clone();
|
||||
match crate::config::load_live_keybinds() {
|
||||
let report = match crate::config::load_live_config() {
|
||||
Ok(loaded) => self.apply_live_config(&loaded.config, notify_success),
|
||||
Err(diagnostics) => {
|
||||
self.state.toast = None;
|
||||
self.state.config_diagnostic =
|
||||
crate::config::config_diagnostic_summary(&diagnostics);
|
||||
self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8));
|
||||
crate::config::ConfigReloadReport {
|
||||
status: crate::config::ConfigReloadStatus::Failed,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
};
|
||||
self.sync_toast_deadline(previous_toast);
|
||||
report
|
||||
}
|
||||
|
||||
fn apply_live_config(
|
||||
&mut self,
|
||||
config: &crate::config::Config,
|
||||
notify_success: bool,
|
||||
) -> crate::config::ConfigReloadReport {
|
||||
let mut diagnostics = Vec::new();
|
||||
|
||||
match config.live_keybinds() {
|
||||
Ok(live) => {
|
||||
self.state.prefix_code = live.prefix.0;
|
||||
self.state.prefix_mods = live.prefix.1;
|
||||
self.state.keybinds = live.keybinds;
|
||||
self.state.config_diagnostic = None;
|
||||
self.config_diagnostic_deadline = None;
|
||||
self.state.toast = Some(crate::app::state::ToastNotification {
|
||||
kind: crate::app::state::ToastKind::UpdateInstalled,
|
||||
title: "reloaded keybinds".to_string(),
|
||||
context: "using config.toml".to_string(),
|
||||
});
|
||||
}
|
||||
Err(diagnostics) => {
|
||||
let mut message = diagnostics.join("; ");
|
||||
Err(keybind_diagnostics) => {
|
||||
let mut message = keybind_diagnostics.join("; ");
|
||||
if !message.contains("keeping current keybinds") {
|
||||
message.push_str("; keeping current keybinds");
|
||||
}
|
||||
self.state.toast = None;
|
||||
self.state.config_diagnostic = Some(message);
|
||||
self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8));
|
||||
diagnostics.push(message);
|
||||
}
|
||||
}
|
||||
self.sync_toast_deadline(previous_toast);
|
||||
|
||||
diagnostics.extend(config.ui.sound.diagnostics());
|
||||
|
||||
let previous_default_sidebar_width = self.state.default_sidebar_width;
|
||||
self.state.default_sidebar_width = config.ui.sidebar_width;
|
||||
if self.state.sidebar_width == previous_default_sidebar_width {
|
||||
self.state.sidebar_width = config.ui.sidebar_width;
|
||||
}
|
||||
self.state.confirm_close = config.ui.confirm_close;
|
||||
self.state.pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes;
|
||||
self.state.accent = crate::config::parse_color(&config.ui.accent);
|
||||
self.state.sound = config.ui.sound.clone();
|
||||
self.state.toast_config = config.ui.toast.clone();
|
||||
self.state.palette = resolve_palette(config);
|
||||
self.state.theme_name = config
|
||||
.theme
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "catppuccin".to_string());
|
||||
|
||||
let status = if diagnostics.is_empty() {
|
||||
crate::config::ConfigReloadStatus::Applied
|
||||
} else {
|
||||
crate::config::ConfigReloadStatus::Partial
|
||||
};
|
||||
|
||||
if diagnostics.is_empty() {
|
||||
self.state.config_diagnostic = None;
|
||||
self.config_diagnostic_deadline = None;
|
||||
if notify_success {
|
||||
self.state.toast = Some(crate::app::state::ToastNotification {
|
||||
kind: crate::app::state::ToastKind::UpdateInstalled,
|
||||
title: "reloaded config".to_string(),
|
||||
context: "using config.toml".to_string(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
self.state.config_diagnostic = crate::config::config_diagnostic_summary(&diagnostics);
|
||||
self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8));
|
||||
if notify_success {
|
||||
self.state.toast = Some(crate::app::state::ToastNotification {
|
||||
kind: crate::app::state::ToastKind::UpdateInstalled,
|
||||
title: "reloaded config".to_string(),
|
||||
context: "with warnings".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
crate::config::ConfigReloadReport {
|
||||
status,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -794,30 +868,35 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn reload_keybinds_updates_live_state() {
|
||||
fn reload_config_updates_live_state() {
|
||||
let _guard = config_env_lock().lock().unwrap();
|
||||
let path = temp_config_path("reload-keybinds-success");
|
||||
let path = temp_config_path("reload-config-success");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&path,
|
||||
"[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n",
|
||||
"[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n[ui.toast]\ndelivery = \"herdr\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
|
||||
|
||||
let mut app = test_app();
|
||||
app.reload_keybinds();
|
||||
let report = app.reload_config();
|
||||
|
||||
assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
|
||||
assert_eq!(app.state.prefix_code, KeyCode::Char('a'));
|
||||
assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL);
|
||||
assert_eq!(
|
||||
app.state.keybinds.new_workspace,
|
||||
(KeyCode::Char('g'), KeyModifiers::empty())
|
||||
);
|
||||
assert_eq!(
|
||||
app.state.toast_config.delivery,
|
||||
crate::config::ToastDelivery::Herdr
|
||||
);
|
||||
assert!(app.state.config_diagnostic.is_none());
|
||||
let toast = app.state.toast.as_ref().unwrap();
|
||||
assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled);
|
||||
assert_eq!(toast.title, "reloaded keybinds");
|
||||
assert_eq!(toast.title, "reloaded config");
|
||||
assert_eq!(toast.context, "using config.toml");
|
||||
|
||||
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
|
||||
|
|
@ -825,23 +904,32 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn reload_keybinds_keeps_current_state_on_invalid_binding() {
|
||||
fn reload_config_keeps_current_keybinds_on_invalid_binding_but_applies_other_sections() {
|
||||
let _guard = config_env_lock().lock().unwrap();
|
||||
let path = temp_config_path("reload-keybinds-invalid");
|
||||
let path = temp_config_path("reload-config-invalid-keybind");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, "[keys]\nnew_workspace = \"wat\"\n").unwrap();
|
||||
std::fs::write(
|
||||
&path,
|
||||
"[keys]\nnew_workspace = \"wat\"\n[ui.toast]\ndelivery = \"terminal\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
|
||||
|
||||
let mut app = test_app();
|
||||
let original_prefix = (app.state.prefix_code, app.state.prefix_mods);
|
||||
let original_keybinds = app.state.keybinds.new_workspace;
|
||||
app.reload_keybinds();
|
||||
let report = app.reload_config();
|
||||
|
||||
assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial);
|
||||
assert_eq!(
|
||||
(app.state.prefix_code, app.state.prefix_mods),
|
||||
original_prefix
|
||||
);
|
||||
assert_eq!(app.state.keybinds.new_workspace, original_keybinds);
|
||||
assert_eq!(
|
||||
app.state.toast_config.delivery,
|
||||
crate::config::ToastDelivery::Terminal
|
||||
);
|
||||
assert!(app
|
||||
.state
|
||||
.config_diagnostic
|
||||
|
|
@ -850,6 +938,67 @@ mod tests {
|
|||
message.contains("keys.new_workspace")
|
||||
&& message.contains("keeping current keybinds")
|
||||
}));
|
||||
|
||||
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_save_toast_delivery_persists_then_applies_live_config() {
|
||||
let _guard = config_env_lock().lock().unwrap();
|
||||
let path = temp_config_path("settings-save-toast-delivery");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, "onboarding = false\n").unwrap();
|
||||
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
|
||||
|
||||
let mut app = test_app();
|
||||
assert_eq!(
|
||||
app.state.toast_config.delivery,
|
||||
crate::config::ToastDelivery::Off
|
||||
);
|
||||
|
||||
app.save_toast_delivery(crate::config::ToastDelivery::Terminal);
|
||||
|
||||
assert_eq!(
|
||||
app.state.toast_config.delivery,
|
||||
crate::config::ToastDelivery::Terminal
|
||||
);
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("delivery = \"terminal\""));
|
||||
assert!(app.state.config_diagnostic.is_none());
|
||||
|
||||
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_config_keeps_current_state_on_invalid_toml() {
|
||||
let _guard = config_env_lock().lock().unwrap();
|
||||
let path = temp_config_path("reload-config-invalid-toml");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, "[keys\nnew_workspace = \"g\"\n").unwrap();
|
||||
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
|
||||
|
||||
let mut app = test_app();
|
||||
let original_prefix = (app.state.prefix_code, app.state.prefix_mods);
|
||||
let original_keybinds = app.state.keybinds.new_workspace;
|
||||
let original_toast_delivery = app.state.toast_config.delivery;
|
||||
let report = app.reload_config();
|
||||
|
||||
assert_eq!(report.status, crate::config::ConfigReloadStatus::Failed);
|
||||
assert_eq!(
|
||||
(app.state.prefix_code, app.state.prefix_mods),
|
||||
original_prefix
|
||||
);
|
||||
assert_eq!(app.state.keybinds.new_workspace, original_keybinds);
|
||||
assert_eq!(app.state.toast_config.delivery, original_toast_delivery);
|
||||
assert!(app
|
||||
.state
|
||||
.config_diagnostic
|
||||
.as_deref()
|
||||
.is_some_and(|message| {
|
||||
message.contains("config parse error") && message.contains("keeping current config")
|
||||
}));
|
||||
assert!(app.state.toast.is_none());
|
||||
|
||||
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
|
||||
|
|
|
|||
|
|
@ -593,7 +593,7 @@ pub struct AppState {
|
|||
pub detach_requested: bool,
|
||||
pub request_new_workspace: bool,
|
||||
pub request_new_tab: bool,
|
||||
pub request_reload_keybinds: bool,
|
||||
pub request_reload_config: bool,
|
||||
/// Set when UI interaction requested a clipboard write that must be
|
||||
/// handled by the outer App/event loop instead of directly from AppState.
|
||||
pub request_clipboard_write: Option<Vec<u8>>,
|
||||
|
|
@ -636,6 +636,7 @@ pub struct AppState {
|
|||
#[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth
|
||||
pub accent: Color,
|
||||
pub sound: SoundConfig,
|
||||
pub local_sound_playback: bool,
|
||||
pub toast_config: ToastConfig,
|
||||
pub keybinds: Keybinds,
|
||||
/// Frame counter for spinner animations (wraps around).
|
||||
|
|
@ -717,7 +718,7 @@ impl AppState {
|
|||
detach_requested: false,
|
||||
request_new_workspace: false,
|
||||
request_new_tab: false,
|
||||
request_reload_keybinds: false,
|
||||
request_reload_config: false,
|
||||
request_clipboard_write: None,
|
||||
creating_new_tab: false,
|
||||
requested_new_tab_name: None,
|
||||
|
|
@ -767,6 +768,7 @@ impl AppState {
|
|||
enabled: false,
|
||||
..SoundConfig::default()
|
||||
},
|
||||
local_sound_playback: false,
|
||||
toast_config: ToastConfig::default(),
|
||||
keybinds: Keybinds {
|
||||
new_workspace: (KeyCode::Char('n'), KeyModifiers::empty()),
|
||||
|
|
@ -777,6 +779,8 @@ impl AppState {
|
|||
close_workspace_label: "shift+d".into(),
|
||||
detach: None,
|
||||
detach_label: None,
|
||||
reload_config: None,
|
||||
reload_config_label: None,
|
||||
previous_workspace: None,
|
||||
previous_workspace_label: None,
|
||||
next_workspace: None,
|
||||
|
|
|
|||
14
src/cli.rs
14
src/cli.rs
|
|
@ -49,6 +49,7 @@ fn run_server_command(args: &[String]) -> std::io::Result<Option<i32>> {
|
|||
|
||||
match subcommand {
|
||||
"stop" => server_stop(&args[1..]).map(Some),
|
||||
"reload-config" => server_reload_config(&args[1..]).map(Some),
|
||||
"help" | "--help" | "-h" => {
|
||||
print_server_help();
|
||||
Ok(Some(0))
|
||||
|
|
@ -163,6 +164,18 @@ fn server_stop(args: &[String]) -> std::io::Result<i32> {
|
|||
send_ok_request(Method::ServerStop(EmptyParams::default()))
|
||||
}
|
||||
|
||||
fn server_reload_config(args: &[String]) -> std::io::Result<i32> {
|
||||
if !args.is_empty() {
|
||||
eprintln!("usage: herdr server reload-config");
|
||||
return Ok(2);
|
||||
}
|
||||
|
||||
print_response(&send_request(&Request {
|
||||
id: "cli:server:reload-config".into(),
|
||||
method: Method::ServerReloadConfig(EmptyParams::default()),
|
||||
})?)
|
||||
}
|
||||
|
||||
fn workspace_list(args: &[String]) -> std::io::Result<i32> {
|
||||
if !args.is_empty() {
|
||||
eprintln!("usage: herdr workspace list");
|
||||
|
|
@ -1041,6 +1054,7 @@ fn print_server_help() {
|
|||
eprintln!("herdr server commands:");
|
||||
eprintln!(" herdr server run as headless server");
|
||||
eprintln!(" herdr server stop stop the running server via the api socket");
|
||||
eprintln!(" herdr server reload-config reload config.toml in the running server");
|
||||
}
|
||||
|
||||
fn print_workspace_help() {
|
||||
|
|
|
|||
|
|
@ -8,15 +8,15 @@ mod theme;
|
|||
|
||||
pub use self::{
|
||||
io::{
|
||||
config_dir, config_path, load_live_keybinds, remove_section_key, upsert_section_bool,
|
||||
upsert_section_value,
|
||||
config_diagnostic_summary, config_dir, config_path, load_live_config, remove_section_key,
|
||||
upsert_section_bool, upsert_section_value,
|
||||
},
|
||||
keybinds::{
|
||||
format_key_combo, CommandKeybindConfig, CustomCommandAction, CustomCommandKeybind,
|
||||
Keybinds, LiveKeybindConfig,
|
||||
},
|
||||
model::{Config, ToastConfig, ToastDelivery},
|
||||
sound::{AgentSoundSetting, SoundConfig},
|
||||
model::{Config, ConfigReloadReport, ConfigReloadStatus, ToastConfig, ToastDelivery},
|
||||
sound::SoundConfig,
|
||||
theme::{parse_color, CustomThemeColors, ThemeConfig},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use tracing::warn;
|
||||
|
||||
use super::{model::LoadedConfig, Config, LiveKeybindConfig, CONFIG_PATH_ENV_VAR};
|
||||
use super::{model::LoadedConfig, Config, CONFIG_PATH_ENV_VAR};
|
||||
|
||||
pub fn app_dir_name() -> &'static str {
|
||||
if cfg!(debug_assertions) {
|
||||
|
|
@ -77,23 +77,37 @@ pub fn config_path() -> PathBuf {
|
|||
config_dir().join("config.toml")
|
||||
}
|
||||
|
||||
pub fn load_live_keybinds() -> Result<LiveKeybindConfig, Vec<String>> {
|
||||
pub fn config_diagnostic_summary(diagnostics: &[String]) -> Option<String> {
|
||||
if diagnostics.is_empty() {
|
||||
None
|
||||
} else if diagnostics.len() == 1 {
|
||||
Some(diagnostics[0].clone())
|
||||
} else {
|
||||
Some(format!(
|
||||
"{} (and {} more)",
|
||||
diagnostics[0],
|
||||
diagnostics.len() - 1
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_live_config() -> Result<LoadedConfig, Vec<String>> {
|
||||
let path = config_path();
|
||||
if !path.exists() {
|
||||
return Config::default().live_keybinds();
|
||||
return Ok(LoadedConfig {
|
||||
config: Config::default(),
|
||||
diagnostics: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&path).map_err(|err| {
|
||||
vec![format!(
|
||||
"config read error: {err}; keeping current keybinds"
|
||||
)]
|
||||
})?;
|
||||
let config = toml::from_str::<Config>(&content).map_err(|err| {
|
||||
vec![format!(
|
||||
"config parse error: {err}; keeping current keybinds"
|
||||
)]
|
||||
})?;
|
||||
config.live_keybinds()
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.map_err(|err| vec![format!("config read error: {err}; keeping current config")])?;
|
||||
let config = toml::from_str::<Config>(&content)
|
||||
.map_err(|err| vec![format!("config parse error: {err}; keeping current config")])?;
|
||||
Ok(LoadedConfig {
|
||||
config,
|
||||
diagnostics: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ pub struct Keybinds {
|
|||
pub close_workspace_label: String,
|
||||
pub detach: Option<(KeyCode, KeyModifiers)>,
|
||||
pub detach_label: Option<String>,
|
||||
pub reload_config: Option<(KeyCode, KeyModifiers)>,
|
||||
pub reload_config_label: Option<String>,
|
||||
pub previous_workspace: Option<(KeyCode, KeyModifiers)>,
|
||||
pub previous_workspace_label: Option<String>,
|
||||
pub next_workspace: Option<(KeyCode, KeyModifiers)>,
|
||||
|
|
@ -335,6 +337,12 @@ impl Config {
|
|||
&self.keys.detach,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::Navigate,
|
||||
"keys.reload_config",
|
||||
&self.keys.reload_config,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::Navigate,
|
||||
"keys.previous_workspace",
|
||||
|
|
@ -578,28 +586,30 @@ impl Config {
|
|||
close_workspace_label: bindings[2].label.clone(),
|
||||
detach: optional_bindings[0].value,
|
||||
detach_label: optional_bindings[0].label.clone(),
|
||||
previous_workspace: optional_bindings[1].value,
|
||||
previous_workspace_label: optional_bindings[1].label.clone(),
|
||||
next_workspace: optional_bindings[2].value,
|
||||
next_workspace_label: optional_bindings[2].label.clone(),
|
||||
reload_config: optional_bindings[1].value,
|
||||
reload_config_label: optional_bindings[1].label.clone(),
|
||||
previous_workspace: optional_bindings[2].value,
|
||||
previous_workspace_label: optional_bindings[2].label.clone(),
|
||||
next_workspace: optional_bindings[3].value,
|
||||
next_workspace_label: optional_bindings[3].label.clone(),
|
||||
new_tab: bindings[3].value,
|
||||
new_tab_label: bindings[3].label.clone(),
|
||||
rename_tab: optional_bindings[3].value,
|
||||
rename_tab_label: optional_bindings[3].label.clone(),
|
||||
previous_tab: optional_bindings[4].value,
|
||||
previous_tab_label: optional_bindings[4].label.clone(),
|
||||
next_tab: optional_bindings[5].value,
|
||||
next_tab_label: optional_bindings[5].label.clone(),
|
||||
close_tab: optional_bindings[6].value,
|
||||
close_tab_label: optional_bindings[6].label.clone(),
|
||||
focus_pane_left: optional_bindings[7].value,
|
||||
focus_pane_left_label: optional_bindings[7].label.clone(),
|
||||
focus_pane_down: optional_bindings[8].value,
|
||||
focus_pane_down_label: optional_bindings[8].label.clone(),
|
||||
focus_pane_up: optional_bindings[9].value,
|
||||
focus_pane_up_label: optional_bindings[9].label.clone(),
|
||||
focus_pane_right: optional_bindings[10].value,
|
||||
focus_pane_right_label: optional_bindings[10].label.clone(),
|
||||
rename_tab: optional_bindings[4].value,
|
||||
rename_tab_label: optional_bindings[4].label.clone(),
|
||||
previous_tab: optional_bindings[5].value,
|
||||
previous_tab_label: optional_bindings[5].label.clone(),
|
||||
next_tab: optional_bindings[6].value,
|
||||
next_tab_label: optional_bindings[6].label.clone(),
|
||||
close_tab: optional_bindings[7].value,
|
||||
close_tab_label: optional_bindings[7].label.clone(),
|
||||
focus_pane_left: optional_bindings[8].value,
|
||||
focus_pane_left_label: optional_bindings[8].label.clone(),
|
||||
focus_pane_down: optional_bindings[9].value,
|
||||
focus_pane_down_label: optional_bindings[9].label.clone(),
|
||||
focus_pane_up: optional_bindings[10].value,
|
||||
focus_pane_up_label: optional_bindings[10].label.clone(),
|
||||
focus_pane_right: optional_bindings[11].value,
|
||||
focus_pane_right_label: optional_bindings[11].label.clone(),
|
||||
split_vertical: bindings[4].value,
|
||||
split_vertical_label: bindings[4].label.clone(),
|
||||
split_horizontal: bindings[5].value,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,20 @@ pub struct ToastConfig {
|
|||
pub delivery: ToastDelivery,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConfigReloadStatus {
|
||||
Applied,
|
||||
Partial,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub struct ConfigReloadReport {
|
||||
pub status: ConfigReloadStatus,
|
||||
pub diagnostics: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
|
|
@ -45,6 +59,8 @@ pub struct KeysConfig {
|
|||
pub close_workspace: String,
|
||||
/// Optional explicit detach shortcut in server/client mode. Unset by default.
|
||||
pub detach: String,
|
||||
/// Reload config.toml in the running app/server. Unset by default.
|
||||
pub reload_config: String,
|
||||
/// Select the previous workspace. Unset by default.
|
||||
pub previous_workspace: String,
|
||||
/// Select the next workspace. Unset by default.
|
||||
|
|
@ -116,6 +132,7 @@ impl Default for KeysConfig {
|
|||
rename_workspace: "shift+n".into(),
|
||||
close_workspace: "shift+d".into(),
|
||||
detach: "".into(),
|
||||
reload_config: "".into(),
|
||||
previous_workspace: "".into(),
|
||||
next_workspace: "".into(),
|
||||
new_tab: "c".into(),
|
||||
|
|
|
|||
15
src/main.rs
15
src/main.rs
|
|
@ -83,6 +83,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
|
|||
# previous_workspace = "" # optional, unset by default
|
||||
# next_workspace = "" # optional, unset by default
|
||||
# detach = "" # optional explicit detach shortcut in server/client mode
|
||||
# reload_config = "" # optional shortcut to reload config.toml without restarting
|
||||
# new_tab = "c"
|
||||
# rename_tab = "" # optional, unset by default
|
||||
# previous_tab = "" # optional, unset by default
|
||||
|
|
@ -190,6 +191,7 @@ fn main() -> io::Result<()> {
|
|||
println!("Usage: herdr [options]");
|
||||
println!(" herdr update");
|
||||
println!(" herdr server stop");
|
||||
println!(" herdr server reload-config");
|
||||
println!(" herdr workspace <subcommand> ...");
|
||||
println!(" herdr tab <subcommand> ...");
|
||||
println!(" herdr pane <subcommand> ...");
|
||||
|
|
@ -199,6 +201,7 @@ fn main() -> io::Result<()> {
|
|||
println!("Commands:");
|
||||
println!(" server Run as headless server (no terminal, persists after client disconnect)");
|
||||
println!(" server stop Stop the running server via the API socket");
|
||||
println!(" server reload-config Reload config.toml in the running server");
|
||||
println!(" client Connect to a running server as a thin client");
|
||||
println!(
|
||||
" update Download and install the latest version (run outside herdr)"
|
||||
|
|
@ -322,17 +325,7 @@ fn main() -> io::Result<()> {
|
|||
}));
|
||||
|
||||
let config = &loaded_config.config;
|
||||
let config_diagnostic = if loaded_config.diagnostics.is_empty() {
|
||||
None
|
||||
} else if loaded_config.diagnostics.len() == 1 {
|
||||
Some(loaded_config.diagnostics[0].clone())
|
||||
} else {
|
||||
Some(format!(
|
||||
"{} (and {} more)",
|
||||
loaded_config.diagnostics[0],
|
||||
loaded_config.diagnostics.len() - 1
|
||||
))
|
||||
};
|
||||
let config_diagnostic = config::config_diagnostic_summary(&loaded_config.diagnostics);
|
||||
logging::startup("app");
|
||||
|
||||
// Background update check (non-blocking, best-effort)
|
||||
|
|
|
|||
|
|
@ -512,9 +512,9 @@ impl HeadlessServer {
|
|||
needs_render = true;
|
||||
}
|
||||
|
||||
if self.app.state.request_reload_keybinds {
|
||||
self.app.state.request_reload_keybinds = false;
|
||||
self.app.reload_keybinds();
|
||||
if self.app.state.request_reload_config {
|
||||
self.app.state.request_reload_config = false;
|
||||
self.app.reload_config();
|
||||
needs_render = true;
|
||||
}
|
||||
|
||||
|
|
@ -800,17 +800,11 @@ impl HeadlessServer {
|
|||
.unwrap_or(crate::detect::AgentState::Unknown);
|
||||
|
||||
// Handle the state change (updates pane state, sets toast on AppState).
|
||||
// Note: apply_pane_state_change inside handle_internal_event will try
|
||||
// to play sounds, but sound.enabled=false in the headless server, so
|
||||
// sound::play is never called. Toast may still be set on AppState if
|
||||
// toast_config.enabled is true.
|
||||
// Headless mode disables local sound playback separately from the
|
||||
// sound policy so reloads can keep server-side notification policy live.
|
||||
self.app.handle_internal_event(ev);
|
||||
|
||||
// Forward sound notification to clients.
|
||||
// We check the agent-specific sound setting but NOT sound.enabled,
|
||||
// because the server sets enabled=false to prevent local playback —
|
||||
// clients should still receive sound notifications and decide
|
||||
// locally whether to play them based on their own config.
|
||||
// Forward sound notification to clients when server-side sound policy allows it.
|
||||
let is_active_tab = self
|
||||
.app
|
||||
.state
|
||||
|
|
@ -821,10 +815,7 @@ impl HeadlessServer {
|
|||
.is_some_and(|tab_idx| ws.active_tab_index() == tab_idx)
|
||||
});
|
||||
|
||||
if !matches!(
|
||||
self.app.state.sound.agents.for_agent(agent_val),
|
||||
crate::config::AgentSoundSetting::Off
|
||||
) {
|
||||
if self.app.state.sound.allows(agent_val) {
|
||||
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
|
||||
is_active_tab,
|
||||
prev_state,
|
||||
|
|
@ -906,9 +897,10 @@ impl HeadlessServer {
|
|||
|
||||
self.app.handle_internal_event(ev);
|
||||
|
||||
// Forward sound notification based on hook state transition.
|
||||
// This ensures API-reported state changes (pane.report_agent)
|
||||
// produce notifications even before fallback detection confirms.
|
||||
// Forward sound notification based on hook state transition when
|
||||
// server-side sound policy allows it. This ensures API-reported state
|
||||
// changes (pane.report_agent) produce notifications even before
|
||||
// fallback detection confirms.
|
||||
let is_active_tab = self
|
||||
.app
|
||||
.state
|
||||
|
|
@ -919,10 +911,7 @@ impl HeadlessServer {
|
|||
.is_some_and(|tab_idx| ws.active_tab_index() == tab_idx)
|
||||
});
|
||||
|
||||
if !matches!(
|
||||
self.app.state.sound.agents.for_agent(agent_val),
|
||||
crate::config::AgentSoundSetting::Off
|
||||
) {
|
||||
if self.app.state.sound.allows(agent_val) {
|
||||
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
|
||||
is_active_tab,
|
||||
prev_hook_state,
|
||||
|
|
@ -1261,8 +1250,8 @@ impl HeadlessServer {
|
|||
// forward any resulting notifications to connected clients.
|
||||
// API requests like pane.report_agent trigger handle_internal_event
|
||||
// internally, which bypasses drain_internal_events_with_forwarding.
|
||||
// Since sound.enabled=false in the headless server, sounds would be
|
||||
// silently dropped; toasts may be set but not forwarded.
|
||||
// Headless mode disables local sound playback, so sound notifications
|
||||
// need to be forwarded to clients here; toasts may be set but not forwarded.
|
||||
//
|
||||
// Note: pane.report_agent sets hook_authority on the pane, but the
|
||||
// effective state may not change until the fallback detector confirms
|
||||
|
|
@ -1411,13 +1400,9 @@ impl HeadlessServer {
|
|||
}
|
||||
}
|
||||
|
||||
// Check agent-specific sound setting but NOT sound.enabled,
|
||||
// because the server sets enabled=false to prevent local playback.
|
||||
// Clients decide locally whether to play sounds.
|
||||
if !matches!(
|
||||
self.app.state.sound.agents.for_agent(agent),
|
||||
crate::config::AgentSoundSetting::Off
|
||||
) {
|
||||
// Forward sound notification when server-side sound policy allows it.
|
||||
// Clients still decide locally whether they can execute the side effect.
|
||||
if self.app.state.sound.allows(agent) {
|
||||
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
|
||||
is_active_tab,
|
||||
prev_state,
|
||||
|
|
@ -1957,7 +1942,7 @@ pub fn run_server() -> io::Result<()> {
|
|||
let mut app = app::App::new(
|
||||
&loaded_config.config,
|
||||
no_session,
|
||||
None, // config_diagnostic
|
||||
config::config_diagnostic_summary(&loaded_config.diagnostics),
|
||||
None, // startup_release_notes
|
||||
api_rx,
|
||||
event_hub,
|
||||
|
|
@ -1966,7 +1951,7 @@ pub fn run_server() -> io::Result<()> {
|
|||
// The server runs headless — disable local sound playback.
|
||||
// Sound notifications are forwarded to connected clients as
|
||||
// ServerMessage::Notify instead of played locally.
|
||||
app.state.sound.enabled = false;
|
||||
app.state.local_sound_playback = false;
|
||||
|
||||
// Create the headless server.
|
||||
let mut server = match HeadlessServer::new(app) {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ pub(super) fn keybind_help_groups(
|
|||
"navigate mode",
|
||||
),
|
||||
("prefix + ?".to_string(), "keybinds"),
|
||||
(
|
||||
optional_keybind_label(&kb.reload_config_label),
|
||||
"reload config",
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
|
|
|
|||
|
|
@ -467,6 +467,16 @@ fn workspace_and_pane_management_commands_work() {
|
|||
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
|
||||
wait_for_socket(&socket_path, Duration::from_secs(5));
|
||||
|
||||
let reloaded = run_cli(&socket_path, &["server", "reload-config"]);
|
||||
assert!(
|
||||
reloaded.status.success(),
|
||||
"stderr: {}",
|
||||
String::from_utf8_lossy(&reloaded.stderr)
|
||||
);
|
||||
let reload_json: serde_json::Value = serde_json::from_slice(&reloaded.stdout).unwrap();
|
||||
assert_eq!(reload_json["result"]["type"], "config_reload");
|
||||
assert_eq!(reload_json["result"]["status"], "applied");
|
||||
|
||||
let listed = run_cli(&socket_path, &["workspace", "list"]);
|
||||
assert!(listed.status.success());
|
||||
let listed_json: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap();
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use std::thread;
|
|||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
||||
use serde::Deserialize;
|
||||
use support::{
|
||||
cleanup_test_base, client_handshake, encode_varint_u16, encode_varint_u32, frame_message,
|
||||
read_server_message, register_runtime_dir, register_spawned_herdr_pid,
|
||||
|
|
@ -157,6 +158,71 @@ fn ping_socket(socket_path: &PathBuf) -> String {
|
|||
response.trim().to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FrameWire {
|
||||
cells: Vec<CellWire>,
|
||||
width: u16,
|
||||
height: u16,
|
||||
cursor: Option<CursorWire>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CellWire {
|
||||
symbol: String,
|
||||
fg: u32,
|
||||
bg: u32,
|
||||
modifier: u16,
|
||||
skip: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CursorWire {
|
||||
x: u16,
|
||||
y: u16,
|
||||
visible: bool,
|
||||
}
|
||||
|
||||
fn decode_frame_payload(payload: &[u8]) -> std::io::Result<FrameWire> {
|
||||
bincode::serde::decode_from_slice(payload, bincode::config::standard())
|
||||
.map(|(frame, consumed)| (frame, consumed))
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string()))
|
||||
.and_then(|(frame, consumed): (FrameWire, usize)| {
|
||||
if consumed != payload.len() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"frame payload had trailing bytes: consumed={}, len={}",
|
||||
consumed,
|
||||
payload.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(frame)
|
||||
})
|
||||
}
|
||||
|
||||
fn frame_contains_text(frame: &FrameWire, needle: &str) -> bool {
|
||||
if frame.cells.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let width = frame.width.max(1) as usize;
|
||||
let mut text = String::new();
|
||||
for row in frame.cells.chunks(width) {
|
||||
for cell in row {
|
||||
let _ = (cell.fg, cell.bg, cell.modifier, cell.skip);
|
||||
text.push_str(&cell.symbol);
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
let _ = frame.height;
|
||||
if let Some(cursor) = frame.cursor.as_ref() {
|
||||
let _ = (cursor.x, cursor.y, cursor.visible);
|
||||
}
|
||||
|
||||
text.contains(needle)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -206,6 +272,91 @@ fn client_connects_and_receives_frame() {
|
|||
cleanup_spawned_herdr(spawned, base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_sees_headless_startup_config_diagnostic() {
|
||||
let _lock = test_lock();
|
||||
let base = unique_test_dir();
|
||||
let config_home = base.join("config");
|
||||
let runtime_dir = base.join("runtime");
|
||||
let api_socket = runtime_dir.join("herdr.sock");
|
||||
let client_socket = runtime_dir.join("herdr-client.sock");
|
||||
|
||||
let app_dir = if cfg!(debug_assertions) {
|
||||
"herdr-dev"
|
||||
} else {
|
||||
"herdr"
|
||||
};
|
||||
fs::create_dir_all(config_home.join(app_dir)).unwrap();
|
||||
fs::write(
|
||||
config_home.join(app_dir).join("config.toml"),
|
||||
"[keys\nprefix = \"ctrl+a\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(&runtime_dir).unwrap();
|
||||
register_runtime_dir(&runtime_dir);
|
||||
|
||||
let pair = native_pty_system()
|
||||
.openpty(PtySize {
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
|
||||
cmd.arg("server");
|
||||
cmd.env("XDG_CONFIG_HOME", &config_home);
|
||||
cmd.env("XDG_RUNTIME_DIR", &runtime_dir);
|
||||
cmd.env("HERDR_SOCKET_PATH", &api_socket);
|
||||
cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
|
||||
cmd.env("SHELL", "/bin/sh");
|
||||
cmd.env_remove("HERDR_ENV");
|
||||
|
||||
let child = pair.slave.spawn_command(cmd).unwrap();
|
||||
register_spawned_herdr_pid(child.process_id());
|
||||
drop(pair.slave);
|
||||
|
||||
let spawned = SpawnedHerdr {
|
||||
_master: pair.master,
|
||||
child,
|
||||
};
|
||||
wait_for_socket(&api_socket, Duration::from_secs(10));
|
||||
wait_for_file(&client_socket, Duration::from_secs(10));
|
||||
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 1);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut found_diagnostic = false;
|
||||
while Instant::now() < deadline {
|
||||
match read_server_message(&mut stream) {
|
||||
Ok((1, payload)) => {
|
||||
let frame = decode_frame_payload(&payload).expect("decode frame");
|
||||
if frame_contains_text(&frame, "config parse error") {
|
||||
found_diagnostic = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
found_diagnostic,
|
||||
"attached client should see startup config parse diagnostic"
|
||||
);
|
||||
|
||||
cleanup_spawned_herdr(spawned, base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_input_forwarded_to_pane() {
|
||||
// Stdin input is forwarded to server as ClientMessage::Input.
|
||||
|
|
|
|||
Loading…
Reference in New Issue