From bd47187c3bdcbe2a2238deb7a15b7d20f57bd3ac Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:48:18 -0500 Subject: [PATCH 1/2] Refine layout config editing --- src/actions/layout.rs | 29 +++++++---- src/common/config_files.rs | 83 ++++++++++++++++++++++++++++++++ src/common/mod.rs | 1 + src/dashboard/widgets/layouts.rs | 39 +++++++++------ src/main.rs | 1 + src/plugins/layout.rs | 24 ++++++--- src/plugins/layouts_storage.rs | 22 ++++++++- src/settings.rs | 14 ++++++ 8 files changed, 180 insertions(+), 33 deletions(-) create mode 100644 src/common/config_files.rs diff --git a/src/actions/layout.rs b/src/actions/layout.rs index 2dbb4c22..ea8c1bf5 100644 --- a/src/actions/layout.rs +++ b/src/actions/layout.rs @@ -10,10 +10,13 @@ //! //! Flags are comma-separated (`,`) and values use `key=value`, for example: //! `layout:load:Work|dry_run,only_active_monitor,filter=chrome`. +use crate::common::config_files::{ensure_config_file, ConfigFileResult}; use crate::plugins::layouts_storage::{ - self, list_layouts as list_saved_layouts, remove_layout as remove_saved_layout, Layout, - LayoutCoordMode, LayoutOptions, LayoutWindowLaunch, LAYOUTS_FILE, + self, layouts_config_path, list_layouts as list_saved_layouts, + remove_layout as remove_saved_layout, Layout, LayoutCoordMode, LayoutOptions, + LayoutWindowLaunch, LAYOUTS_CONFIG, }; +use crate::settings; use crate::windows_layout::{ apply_layout_restore_plan, collect_layout_windows, plan_layout_restore, LayoutMatchResult, LayoutRestoreSummary, LayoutWindowOptions, @@ -556,7 +559,7 @@ pub fn save_layout(name: &str, flags: Option<&str>) -> anyhow::Result<()> { return Ok(()); } - let mut store = layouts_storage::load_layouts(LAYOUTS_FILE)?; + let mut store = layouts_storage::load_layouts(layouts_config_path())?; let windows = collect_layout_windows(LayoutWindowOptions { only_active_monitor: flags.only_active_monitor, include_minimized: flags.include_minimized, @@ -572,14 +575,14 @@ pub fn save_layout(name: &str, flags: Option<&str>) -> anyhow::Result<()> { ignore: Vec::new(), }; layouts_storage::upsert_layout(&mut store, layout); - layouts_storage::save_layouts(LAYOUTS_FILE, &store)?; + layouts_storage::save_layouts(layouts_config_path(), &store)?; Ok(()) } pub fn load_layout(name: &str, flags: Option<&str>) -> anyhow::Result<()> { ensure_layout_name(name)?; let flags = parse_flags(flags); - let store = layouts_storage::load_layouts(LAYOUTS_FILE)?; + let store = layouts_storage::load_layouts(layouts_config_path())?; let layout = layouts_storage::get_layout(&store, name) .ok_or_else(|| anyhow::anyhow!("layout '{name}' not found"))?; let layout = filter_layout_by_groups(layout, &flags.only_groups); @@ -665,7 +668,7 @@ pub fn load_layout(name: &str, flags: Option<&str>) -> anyhow::Result<()> { pub fn show_layout(name: &str, flags: Option<&str>) -> anyhow::Result<()> { ensure_layout_name(name)?; let flags = parse_flags(flags); - let store = layouts_storage::load_layouts(LAYOUTS_FILE)?; + let store = layouts_storage::load_layouts(layouts_config_path())?; let layout = layouts_storage::get_layout(&store, name) .ok_or_else(|| anyhow::anyhow!("layout '{name}' not found"))?; let layout = filter_layout_by_groups(layout, &flags.only_groups); @@ -709,14 +712,20 @@ pub fn show_layout(name: &str, flags: Option<&str>) -> anyhow::Result<()> { } pub fn edit_layouts() -> anyhow::Result<()> { - open::that(LAYOUTS_FILE)?; + let settings_path = settings::settings_path(); + let result = ensure_config_file(&settings_path, &LAYOUTS_CONFIG)?; + match &result { + ConfigFileResult::Opened { path } | ConfigFileResult::Created { path } => { + open::that(path)?; + } + } Ok(()) } pub fn remove_layout(name: &str, flags: Option<&str>) -> anyhow::Result<()> { ensure_layout_name(name)?; let flags = parse_flags(flags); - let mut store = layouts_storage::load_layouts(LAYOUTS_FILE)?; + let mut store = layouts_storage::load_layouts(layouts_config_path())?; if layouts_storage::get_layout(&store, name).is_none() { anyhow::bail!("layout '{name}' not found"); } @@ -724,7 +733,7 @@ pub fn remove_layout(name: &str, flags: Option<&str>) -> anyhow::Result<()> { return Ok(()); } let _ = remove_saved_layout(&mut store, name); - layouts_storage::save_layouts(LAYOUTS_FILE, &store)?; + layouts_storage::save_layouts(layouts_config_path(), &store)?; Ok(()) } @@ -732,7 +741,7 @@ pub fn list_layouts(flags: Option<&str>) -> anyhow::Result<()> { use std::fmt::Write as _; let flags = parse_flags(flags); - let store = layouts_storage::load_layouts(LAYOUTS_FILE)?; + let store = layouts_storage::load_layouts(layouts_config_path())?; let mut list = list_saved_layouts(&store); if let Some(filter) = flags.filter { let needle = filter.to_lowercase(); diff --git a/src/common/config_files.rs b/src/common/config_files.rs new file mode 100644 index 00000000..fd2ee377 --- /dev/null +++ b/src/common/config_files.rs @@ -0,0 +1,83 @@ +use std::path::{Path, PathBuf}; + +pub enum ConfigFileResult { + Opened { path: PathBuf }, + Created { path: PathBuf }, +} + +pub struct ConfigFileSpec<'a> { + pub label: &'a str, + pub relative_path: &'a str, + pub default_contents: &'a str, +} + +impl<'a> ConfigFileSpec<'a> { + pub const fn new(label: &'a str, relative_path: &'a str, default_contents: &'a str) -> Self { + Self { + label, + relative_path, + default_contents, + } + } +} + +pub fn resolve_config_path(settings_path: &Path, spec: &ConfigFileSpec<'_>) -> PathBuf { + let base_dir = settings_path.parent().unwrap_or_else(|| Path::new(".")); + base_dir.join(spec.relative_path) +} + +pub fn ensure_config_file( + settings_path: &Path, + spec: &ConfigFileSpec<'_>, +) -> anyhow::Result { + let path = resolve_config_path(settings_path, spec); + if path.exists() { + return Ok(ConfigFileResult::Opened { path }); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, spec.default_contents)?; + Ok(ConfigFileResult::Created { path }) +} + +#[cfg(test)] +mod tests { + use super::{ensure_config_file, resolve_config_path, ConfigFileResult, ConfigFileSpec}; + use std::path::Path; + + #[test] + fn resolves_path_relative_to_settings_dir() { + let dir = tempfile::tempdir().expect("tempdir"); + let settings_path = dir.path().join("settings.json"); + let spec = ConfigFileSpec::new("test", "configs/test.json", "{}"); + let resolved = resolve_config_path(&settings_path, &spec); + assert_eq!( + resolved, + dir.path().join(Path::new("configs").join("test.json")) + ); + } + + #[test] + fn creates_and_reuses_config_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let settings_path = dir.path().join("settings.json"); + let spec = ConfigFileSpec::new("test", "test.json", "default"); + + let created = ensure_config_file(&settings_path, &spec).expect("create file"); + let path = match created { + ConfigFileResult::Created { path } => path, + ConfigFileResult::Opened { .. } => panic!("expected create"), + }; + let contents = std::fs::read_to_string(&path).expect("read file"); + assert_eq!(contents, "default"); + + let opened = ensure_config_file(&settings_path, &spec).expect("open file"); + match opened { + ConfigFileResult::Opened { path: opened_path } => { + assert_eq!(opened_path, path); + } + ConfigFileResult::Created { .. } => panic!("expected open"), + } + } +} diff --git a/src/common/mod.rs b/src/common/mod.rs index 50a5c923..c9a0b3ea 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -7,6 +7,7 @@ pub fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { } pub mod command; +pub mod config_files; pub mod json_watch; pub mod slug; pub mod lru; diff --git a/src/dashboard/widgets/layouts.rs b/src/dashboard/widgets/layouts.rs index 79611cc7..ac526504 100644 --- a/src/dashboard/widgets/layouts.rs +++ b/src/dashboard/widgets/layouts.rs @@ -5,7 +5,7 @@ use super::{ }; use crate::actions::Action; use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; -use crate::plugins::layouts_storage::{self, Layout, LayoutMatch, LayoutStore, LAYOUTS_FILE}; +use crate::plugins::layouts_storage::{self, layouts_config_path, Layout, LayoutMatch, LayoutStore}; use crate::windows_layout::{collect_layout_windows, LayoutWindowOptions}; use chrono::Utc; use eframe::egui; @@ -174,7 +174,7 @@ impl LayoutsWidget { } fn load_active_layout(&mut self, name: &str) -> Result<(), String> { - let store = layouts_storage::load_layouts(LAYOUTS_FILE) + let store = layouts_storage::load_layouts(layouts_config_path()) .map_err(|err| format!("Failed to load layouts: {err}"))?; let layout = layouts_storage::get_layout(&store, name) .ok_or_else(|| "Layout not found.".to_string())? @@ -187,10 +187,10 @@ impl LayoutsWidget { } fn save_layout(&mut self, layout: Layout) -> Result<(), String> { - let mut store = layouts_storage::load_layouts(LAYOUTS_FILE) + let mut store = layouts_storage::load_layouts(layouts_config_path()) .map_err(|err| format!("Failed to load layouts: {err}"))?; layouts_storage::upsert_layout(&mut store, layout.clone()); - layouts_storage::save_layouts(LAYOUTS_FILE, &store) + layouts_storage::save_layouts(layouts_config_path(), &store) .map_err(|err| format!("Failed to save layouts: {err}"))?; self.active_layout_name = Some(layout.name.clone()); self.active_layout = Some(layout.clone()); @@ -234,14 +234,14 @@ impl LayoutsWidget { let Some(layout) = self.active_layout.clone() else { return Err("No active layout selected.".to_string()); }; - let mut store = layouts_storage::load_layouts(LAYOUTS_FILE) + let mut store = layouts_storage::load_layouts(layouts_config_path()) .map_err(|err| format!("Failed to load layouts: {err}"))?; let new_name = Self::unique_layout_name(&layout.name, &store, " (copy)"); let mut cloned = layout.clone(); cloned.name = new_name.clone(); cloned.created_at = Some(Utc::now().to_rfc3339()); layouts_storage::upsert_layout(&mut store, cloned.clone()); - layouts_storage::save_layouts(LAYOUTS_FILE, &store) + layouts_storage::save_layouts(layouts_config_path(), &store) .map_err(|err| format!("Failed to save layouts: {err}"))?; self.active_layout_name = Some(new_name.clone()); self.active_layout = Some(cloned.clone()); @@ -321,7 +321,7 @@ impl LayoutsWidget { } fn load_layouts(cfg: &LayoutsConfig) -> (LayoutsData, Option) { - let store = match layouts_storage::load_layouts(LAYOUTS_FILE) { + let store = match layouts_storage::load_layouts(layouts_config_path()) { Ok(store) => store, Err(err) => { return ( @@ -407,7 +407,7 @@ impl LayoutsWidget { if new_name == from { return Ok(()); } - let mut store = layouts_storage::load_layouts(LAYOUTS_FILE) + let mut store = layouts_storage::load_layouts(layouts_config_path()) .map_err(|err| format!("Failed to load layouts: {err}"))?; if store.layouts.iter().any(|layout| layout.name == new_name) { return Err("A layout with that name already exists.".to_string()); @@ -416,7 +416,7 @@ impl LayoutsWidget { return Err("Layout not found.".to_string()); }; layout.name = new_name.to_string(); - layouts_storage::save_layouts(LAYOUTS_FILE, &store) + layouts_storage::save_layouts(layouts_config_path(), &store) .map_err(|err| format!("Failed to save layouts: {err}"))?; if self.active_layout_name.as_deref() == Some(from) { if let Some(active) = self.active_layout.as_mut() { @@ -602,7 +602,7 @@ impl Widget for LayoutsWidget { self.pending_import = None; } if ui.button("Import as new").clicked() { - let mut store = match layouts_storage::load_layouts(LAYOUTS_FILE) { + let mut store = match layouts_storage::load_layouts(layouts_config_path()) { Ok(store) => store, Err(err) => { self.set_status( @@ -618,7 +618,7 @@ impl Widget for LayoutsWidget { Self::unique_layout_name(&imported.name, &store, " (imported)"); imported.name = new_name.clone(); layouts_storage::upsert_layout(&mut store, imported.clone()); - if let Err(err) = layouts_storage::save_layouts(LAYOUTS_FILE, &store) { + if let Err(err) = layouts_storage::save_layouts(layouts_config_path(), &store) { self.set_status( format!("Failed to save layouts: {err}"), egui::Color32::YELLOW, @@ -670,10 +670,21 @@ impl Widget for LayoutsWidget { )); ui.close_menu(); } - if ui.button("Edit JSON").clicked() { + let layouts_path = layouts_config_path(); + let layouts_exists = layouts_path.exists(); + let layouts_label = if layouts_exists { + "Edit layouts.json" + } else { + "Create layouts.json" + }; + if ui.button(layouts_label).clicked() { selected = Some(Self::action( - "Open layouts.json".to_string(), - LAYOUTS_FILE.to_string(), + format!( + "{} ({})", + layouts_label, + layouts_path.to_string_lossy() + ), + "layout:edit".to_string(), )); ui.close_menu(); } diff --git a/src/main.rs b/src/main.rs index fa6be271..7affa0bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -145,6 +145,7 @@ fn spawn_gui( fn main() -> anyhow::Result<()> { let mut settings = Settings::load("settings.json").unwrap_or_default(); + multi_launcher::settings::set_settings_path("settings.json"); logging::init(settings.debug_logging, settings.log_file_path()); tracing::debug!(?settings, "settings loaded"); let mut actions_vec = load_actions("actions.json").unwrap_or_default(); diff --git a/src/plugins/layout.rs b/src/plugins/layout.rs index 842ea4d0..42732b1f 100644 --- a/src/plugins/layout.rs +++ b/src/plugins/layout.rs @@ -1,6 +1,8 @@ use crate::actions::Action; use crate::plugin::Plugin; -use crate::plugins::layouts_storage::{get_layout, load_layouts, LayoutMatch, LAYOUTS_FILE}; +use crate::plugins::layouts_storage::{ + get_layout, layouts_config_path, load_layouts, LayoutMatch, +}; #[derive(Default, Debug, Clone)] struct LayoutFlags { @@ -151,6 +153,14 @@ pub struct LayoutPlugin; impl Plugin for LayoutPlugin { fn search(&self, query: &str) -> Vec { + let config_path = layouts_config_path(); + let config_exists = config_path.exists(); + let config_label = if config_exists { + "Edit layouts.json" + } else { + "Create layouts.json" + }; + let config_desc = config_path.to_string_lossy(); let trimmed = query.trim(); let rest = match crate::common::strip_prefix_ci(trimmed, "layout") { Some(rest) => rest.trim(), @@ -184,8 +194,8 @@ impl Plugin for LayoutPlugin { args: None, }, Action { - label: "layout edit".into(), - desc: "Layout".into(), + label: format!("{config_label} ({config_desc})"), + desc: "Layout config".into(), action: "layout:edit".into(), args: None, }, @@ -200,7 +210,7 @@ impl Plugin for LayoutPlugin { if let Some(rest) = crate::common::strip_prefix_ci(rest, "list") { let filter = rest.trim().to_lowercase(); - if let Ok(store) = load_layouts(LAYOUTS_FILE) { + if let Ok(store) = load_layouts(layouts_config_path()) { return store .layouts .iter() @@ -249,7 +259,7 @@ impl Plugin for LayoutPlugin { if let Some(rest) = crate::common::strip_prefix_ci(rest, "show") { let (name, flags) = parse_name_and_flags(rest.trim()); - if let Ok(store) = load_layouts(LAYOUTS_FILE) { + if let Ok(store) = load_layouts(layouts_config_path()) { if !name.is_empty() { if let Some(layout) = get_layout(&store, &name) { let action = build_action(format!("layout:show:{}", layout.name), &flags); @@ -314,8 +324,8 @@ impl Plugin for LayoutPlugin { if let Some(rest) = crate::common::strip_prefix_ci(rest, "edit") { if rest.trim().is_empty() { return vec![Action { - label: "Edit layouts.json".into(), - desc: "Layout".into(), + label: format!("{config_label} ({config_desc})"), + desc: "Layout config".into(), action: "layout:edit".into(), args: None, }]; diff --git a/src/plugins/layouts_storage.rs b/src/plugins/layouts_storage.rs index a4bca317..7cfbaf1c 100644 --- a/src/plugins/layouts_storage.rs +++ b/src/plugins/layouts_storage.rs @@ -1,7 +1,17 @@ +use crate::common::config_files::{resolve_config_path, ConfigFileSpec}; +use crate::settings; use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; pub const LAYOUTS_FILE: &str = "layouts.json"; +pub const DEFAULT_LAYOUTS_TEMPLATE: &str = r#"{ + "version": 1, + "layouts": [] +} +"#; +pub const LAYOUTS_CONFIG: ConfigFileSpec<'static> = + ConfigFileSpec::new("layouts", LAYOUTS_FILE, DEFAULT_LAYOUTS_TEMPLATE); static LAYOUTS_VERSION: AtomicU64 = AtomicU64::new(0); @@ -155,7 +165,11 @@ pub fn bump_layouts_version() { LAYOUTS_VERSION.fetch_add(1, Ordering::SeqCst); } -pub fn load_layouts(path: &str) -> anyhow::Result { +pub fn layouts_config_path() -> PathBuf { + resolve_config_path(&settings::settings_path(), &LAYOUTS_CONFIG) +} + +pub fn load_layouts(path: impl AsRef) -> anyhow::Result { let content = std::fs::read_to_string(path).unwrap_or_default(); if content.trim().is_empty() { return Ok(LayoutStore::default()); @@ -167,8 +181,12 @@ pub fn load_layouts(path: &str) -> anyhow::Result { Ok(store) } -pub fn save_layouts(path: &str, store: &LayoutStore) -> anyhow::Result<()> { +pub fn save_layouts(path: impl AsRef, store: &LayoutStore) -> anyhow::Result<()> { let json = serde_json::to_string_pretty(store)?; + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } std::fs::write(path, json)?; bump_layouts_version(); Ok(()) diff --git a/src/settings.rs b/src/settings.rs index e76347ac..e0a9858a 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -3,6 +3,7 @@ use crate::hotkey::Key; use crate::gui::Panel; use crate::hotkey::{parse_hotkey, Hotkey}; use serde::{Deserialize, Serialize}; +use once_cell::sync::OnceCell; use std::collections::HashSet; use std::path::PathBuf; @@ -208,6 +209,19 @@ pub struct Settings { pub dashboard: DashboardSettings, } +static SETTINGS_PATH: OnceCell = OnceCell::new(); + +pub fn set_settings_path(path: impl Into) { + let _ = SETTINGS_PATH.set(path.into()); +} + +pub fn settings_path() -> PathBuf { + SETTINGS_PATH + .get() + .cloned() + .unwrap_or_else(|| PathBuf::from("settings.json")) +} + fn default_toasts() -> bool { true } From fcb8b6f2fd309d4c060ff47ad6df4b26e68c623c Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:56:52 -0500 Subject: [PATCH 2/2] Stabilize clipboard persistence tests --- src/plugins/clipboard.rs | 3 +++ tests/clipboard_persistence.rs | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/src/plugins/clipboard.rs b/src/plugins/clipboard.rs index b6600624..2fa3e1d8 100644 --- a/src/plugins/clipboard.rs +++ b/src/plugins/clipboard.rs @@ -112,6 +112,9 @@ impl ClipboardPlugin { Some(h) => h, None => return VecDeque::new(), }; + if std::env::var("ML_SKIP_CLIPBOARD_SYNC").is_ok() { + return history.clone(); + } let mut cb_lock = match self.clipboard.lock().ok() { Some(c) => c, None => return history.clone(), diff --git a/tests/clipboard_persistence.rs b/tests/clipboard_persistence.rs index 1c11339a..a953c520 100644 --- a/tests/clipboard_persistence.rs +++ b/tests/clipboard_persistence.rs @@ -23,6 +23,7 @@ fn history_survives_instances() { let _ = clipboard.set_text("first".to_string()); } + std::env::set_var("ML_SKIP_CLIPBOARD_SYNC", "1"); let plugin1 = ClipboardPlugin::new(20); let results1 = plugin1.search("cb first"); assert_eq!(results1.len(), 1); @@ -32,6 +33,7 @@ fn history_survives_instances() { let results2 = plugin2.search("cb first"); assert_eq!(results2.len(), 1); assert_eq!(results2[0].label, "first"); + std::env::remove_var("ML_SKIP_CLIPBOARD_SYNC"); } #[test] @@ -50,9 +52,11 @@ fn cb_list_returns_all_entries() { let _ = clipboard.set_text("alpha".to_string()); } + std::env::set_var("ML_SKIP_CLIPBOARD_SYNC", "1"); let plugin = ClipboardPlugin::new(20); let results = plugin.search("cb list"); assert_eq!(results.len(), 2); assert!(results[0].action.starts_with("clipboard:copy:")); assert!(results[1].action.starts_with("clipboard:copy:")); + std::env::remove_var("ML_SKIP_CLIPBOARD_SYNC"); }