aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs2
-rw-r--r--src/app/api.rs10
-rw-r--r--src/app/config_io.rs25
-rw-r--r--src/app/input/modal.rs8
-rw-r--r--src/app/input/navigate.rs26
-rw-r--r--src/app/input/settings.rs8
-rw-r--r--src/app/input/sidebar.rs12
-rw-r--r--src/app/mod.rs203
-rw-r--r--src/app/state.rs8
9 files changed, 247 insertions, 55 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index b1bdfc63..210b6ccf 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -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,
diff --git a/src/app/api.rs b/src/app/api.rs
index 364097dc..d7893d6d 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -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 {
diff --git a/src/app/config_io.rs b/src/app/config_io.rs
index 6770d61e..6c51ad9f 100644
--- a/src/app/config_io.rs
+++ b/src/app/config_io.rs
@@ -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);
+ }
}
}
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs
index aa25ea1e..998bb69b 100644
--- a/src/app/input/modal.rs
+++ b/src/app/input/modal.rs
@@ -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),
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 23b93379..b961c6d3 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -350,6 +350,7 @@ pub(crate) enum NavigateAction {
Fullscreen,
EnterResizeMode,
ToggleSidebar,
+ ReloadConfig,
Detach,
}
@@ -422,6 +423,12 @@ fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateA
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);
@@ -583,6 +594,21 @@ mod tests {
}
#[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"]);
state.selected = 0;
diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs
index 8bf06dc6..2c92bc63 100644
--- a/src/app/input/settings.rs
+++ b/src/app/input/settings.rs
@@ -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);
}
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs
index 9de8868b..0abfd446 100644
--- a/src/app/input/sidebar.rs
+++ b/src/app/input/sidebar.rs
@@ -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"
]
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 14c50f7f..c4806a77 100644
--- a/src/app/mod.rs
+++ b/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;
+ }
+ Err(keybind_diagnostics) => {
+ let mut message = keybind_diagnostics.join("; ");
+ if !message.contains("keeping current keybinds") {
+ message.push_str("; keeping current keybinds");
+ }
+ diagnostics.push(message);
+ }
+ }
+
+ 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 keybinds".to_string(),
+ title: "reloaded config".to_string(),
context: "using config.toml".to_string(),
});
}
- Err(diagnostics) => {
- let mut message = 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));
+ } 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(),
+ });
}
}
- self.sync_toast_deadline(previous_toast);
+
+ 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);
diff --git a/src/app/state.rs b/src/app/state.rs
index e54b431a..a51a72e7 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -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,