From 9bf8986d24974655b9f880c60443232fed331c86 Mon Sep 17 00:00:00 2001 From: Lars Norman Date: Tue, 6 Jan 2026 23:25:41 +0100 Subject: [PATCH 1/3] feat: preserve YAML file order for task display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks in the status panel and process panels are now displayed in the order they appear in the YAML configuration file, rather than being sorted alphabetically. This makes it easier to arrange tasks in a meaningful order in the config file. Changes: - Replace HashMap with IndexMap for Config.tasks to preserve insertion order during YAML parsing - Remove alphabetical sorting of task names in app.rs - Add indexmap dependency with serde support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 3 +++ rote/Cargo.toml | 1 + rote/src/app.rs | 30 +++++++++++++++--------------- rote/src/config.rs | 7 ++++--- rote/src/task_manager.rs | 3 ++- rote/tests/integration_test.rs | 4 ++-- 6 files changed, 27 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2697ec9..c4c1965 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -326,6 +326,8 @@ checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -554,6 +556,7 @@ dependencies = [ "chrono", "clap", "crossterm", + "indexmap", "nix", "ratatui", "ropey", diff --git a/rote/Cargo.toml b/rote/Cargo.toml index 83abb4e..3e4dbb7 100644 --- a/rote/Cargo.toml +++ b/rote/Cargo.toml @@ -21,6 +21,7 @@ nix = { version = "0.28", features = ["signal"] } tokio = { version = "1.37", features = ["macros", "rt-multi-thread", "process", "io-util", "time", "sync"] } ratatui = "0.26" ropey = "1.6" +indexmap = { version = "2.7", features = ["serde"] } serde = { version = "1.0.228", features = ["derive"] } serde_yaml = "0.9.34" shell-words = "1.1.1" diff --git a/rote/src/app.rs b/rote/src/app.rs index aec76dd..e511202 100644 --- a/rote/src/app.rs +++ b/rote/src/app.rs @@ -74,12 +74,12 @@ pub async fn run_with_input( let tasks_list = resolve_dependencies(&config, &target_tasks)?; // Create panels for ALL tasks with actions (not just those being started) - // Sort by task name for consistent ordering + // Panels are ordered according to their order in the YAML config file let mut panels = Vec::new(); let mut task_to_panel: HashMap = HashMap::new(); - // Collect and sort task names for deterministic panel order - let mut task_names: Vec<_> = config + // Collect task names, preserving YAML file order (IndexMap preserves insertion order) + let task_names: Vec<_> = config .tasks .iter() .filter(|(_, cfg)| { @@ -90,7 +90,6 @@ pub async fn run_with_input( }) .map(|(name, _)| name.clone()) .collect(); - task_names.sort(); for task_name in &task_names { let task_config = config.tasks.get(task_name).unwrap(); @@ -142,7 +141,7 @@ pub async fn run_with_input( return Ok(()); } - // Initialize status panel with all tasks that have actions (sorted order) + // Initialize status panel with all tasks that have actions (YAML file order) let mut status_panel = StatusPanel::new(); for task_name in &task_names { let task_config = config.tasks.get(task_name).unwrap(); @@ -784,6 +783,7 @@ fn toggle_stream_visibility(panel: &mut Panel, show: bool) { #[cfg(test)] mod tests { use super::*; + use indexmap::IndexMap; #[test] fn test_visible_len_empty_panel() { @@ -849,7 +849,7 @@ mod tests { fn test_resolve_dependencies_empty() { let config = Config { default: None, - tasks: HashMap::new(), + tasks: IndexMap::new(), }; let result = resolve_dependencies(&config, &[]).unwrap(); assert!(result.is_empty()); @@ -857,7 +857,7 @@ mod tests { #[test] fn test_resolve_dependencies_no_deps() { - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); tasks.insert( "task1".to_string(), crate::config::TaskConfiguration { @@ -881,7 +881,7 @@ mod tests { #[test] fn test_resolve_dependencies_with_deps() { - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); tasks.insert( "task1".to_string(), crate::config::TaskConfiguration { @@ -916,7 +916,7 @@ mod tests { #[test] fn test_resolve_dependencies_multiple_deps() { - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); tasks.insert( "task1".to_string(), crate::config::TaskConfiguration { @@ -965,7 +965,7 @@ mod tests { #[test] fn test_resolve_dependencies_nested_deps() { - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); tasks.insert( "task1".to_string(), crate::config::TaskConfiguration { @@ -1011,7 +1011,7 @@ mod tests { #[test] fn test_resolve_dependencies_circular() { - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); tasks.insert( "task1".to_string(), crate::config::TaskConfiguration { @@ -1054,7 +1054,7 @@ mod tests { fn test_resolve_dependencies_task_not_found() { let config = Config { default: None, - tasks: HashMap::new(), + tasks: IndexMap::new(), }; let result = resolve_dependencies(&config, &["nonexistent".to_string()]); @@ -1069,7 +1069,7 @@ mod tests { #[test] fn test_resolve_dependencies_dep_not_found() { - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); tasks.insert( "task1".to_string(), crate::config::TaskConfiguration { @@ -1093,7 +1093,7 @@ mod tests { #[test] fn test_resolve_dependencies_multiple_targets() { - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); tasks.insert( "task1".to_string(), crate::config::TaskConfiguration { @@ -1143,7 +1143,7 @@ mod tests { #[test] fn test_resolve_dependencies_diamond_graph() { - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); tasks.insert( "task1".to_string(), crate::config::TaskConfiguration { diff --git a/rote/src/config.rs b/rote/src/config.rs index a1db66a..7dc2a53 100644 --- a/rote/src/config.rs +++ b/rote/src/config.rs @@ -1,12 +1,13 @@ +use indexmap::IndexMap; use serde::Deserialize; -use std::{borrow::Cow, collections::HashMap}; +use std::borrow::Cow; #[derive(Debug, Deserialize)] pub struct Config { /// The default task to run when none is specified. pub default: Option, - /// A mapping of task names to their configurations. - pub tasks: HashMap, + /// A mapping of task names to their configurations (preserves YAML order). + pub tasks: IndexMap, } #[derive(Debug, Deserialize)] diff --git a/rote/src/task_manager.rs b/rote/src/task_manager.rs index b51455c..3401978 100644 --- a/rote/src/task_manager.rs +++ b/rote/src/task_manager.rs @@ -130,10 +130,11 @@ pub fn resolve_dependencies(config: &Config, targets: &[String]) -> Result, Vec<&str>)>) -> Config { - let mut task_map = HashMap::new(); + let mut task_map = IndexMap::new(); for (name, action, require) in tasks { task_map.insert( name.to_string(), diff --git a/rote/tests/integration_test.rs b/rote/tests/integration_test.rs index b0e89e4..ff1068d 100644 --- a/rote/tests/integration_test.rs +++ b/rote/tests/integration_test.rs @@ -1,6 +1,6 @@ +use indexmap::IndexMap; use rote_mux::panel::PanelIndex; use rote_mux::{Config, UiEvent}; -use std::collections::HashMap; use std::time::Duration; use tokio::time::timeout; @@ -100,7 +100,7 @@ async fn test_ensure_dependency_blocks_until_complete() { use rote_mux::config::{CommandValue, TaskAction, TaskConfiguration}; use std::borrow::Cow; - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); // An Ensure task that completes quickly tasks.insert( From 15d90686c97ec58ea2310df27018e5f5a245279f Mon Sep 17 00:00:00 2001 From: Lars Norman Date: Tue, 6 Jan 2026 23:28:06 +0100 Subject: [PATCH 2/3] test: add tests for YAML task order preservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two tests to verify that task order from the YAML file is preserved: - test_task_order_preserved_from_yaml: tests with inline YAML - test_example_yaml_task_order: tests with the example.yaml fixture 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- rote/src/config.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/rote/src/config.rs b/rote/src/config.rs index 7dc2a53..0cad99c 100644 --- a/rote/src/config.rs +++ b/rote/src/config.rs @@ -153,6 +153,44 @@ mod tests { ); } + #[test] + fn test_task_order_preserved_from_yaml() { + let yaml = r#" +default: main +tasks: + first: + run: echo first + second: + run: echo second + third: + run: echo third + fourth: + ensure: true +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let task_names: Vec<_> = config.tasks.keys().collect(); + assert_eq!(task_names, vec!["first", "second", "third", "fourth"]); + } + + #[test] + fn test_example_yaml_task_order() { + let config = load_config(); + let task_names: Vec<_> = config.tasks.keys().collect(); + // Tasks should be in the order they appear in example.yaml + assert_eq!( + task_names, + vec![ + "google-ping", + "cloudflare-ping", + "github-ping", + "short-lived", + "auto-restarting", + "setup-task", + "ping-demo" + ] + ); + } + #[test] fn test_missing_optional_fields() { let yaml = r#" From 9d497f6b3191eb9d24118ce3a8911c762ae01d7d Mon Sep 17 00:00:00 2001 From: Lars Norman Date: Tue, 6 Jan 2026 23:29:24 +0100 Subject: [PATCH 3/3] test: add test for panel order matching YAML order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify that panels are created in YAML file order, not alphabetical. The test uses intentionally non-alphabetical task names (third, first, second) to confirm insertion order is preserved. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- rote/src/app.rs | 85 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/rote/src/app.rs b/rote/src/app.rs index e511202..dd78e72 100644 --- a/rote/src/app.rs +++ b/rote/src/app.rs @@ -785,6 +785,91 @@ mod tests { use super::*; use indexmap::IndexMap; + use crate::config::{CommandValue, TaskConfiguration}; + use std::borrow::Cow; + + /// Helper to extract panel names from a config in the order they would be created. + /// This mirrors the logic in run_with_input for creating panels. + fn get_panel_order(config: &Config) -> Vec { + config + .tasks + .iter() + .filter(|(_, cfg)| { + matches!( + cfg.action, + Some(TaskAction::Run { .. }) | Some(TaskAction::Ensure { .. }) + ) + }) + .map(|(name, _)| name.clone()) + .collect() + } + + #[test] + fn test_panel_order_matches_yaml_order() { + let mut tasks = IndexMap::new(); + tasks.insert( + "third".to_string(), + TaskConfiguration { + action: Some(TaskAction::Run { + command: CommandValue::String(Cow::Borrowed("echo third")), + }), + cwd: None, + display: None, + require: vec![], + autorestart: false, + timestamps: false, + }, + ); + tasks.insert( + "first".to_string(), + TaskConfiguration { + action: Some(TaskAction::Run { + command: CommandValue::String(Cow::Borrowed("echo first")), + }), + cwd: None, + display: None, + require: vec![], + autorestart: false, + timestamps: false, + }, + ); + tasks.insert( + "second".to_string(), + TaskConfiguration { + action: Some(TaskAction::Ensure { + command: CommandValue::String(Cow::Borrowed("echo second")), + }), + cwd: None, + display: None, + require: vec![], + autorestart: false, + timestamps: false, + }, + ); + // Task without action should be excluded from panels + tasks.insert( + "no-action".to_string(), + TaskConfiguration { + action: None, + cwd: None, + display: None, + require: vec!["first".to_string()], + autorestart: false, + timestamps: false, + }, + ); + + let config = Config { + default: None, + tasks, + }; + + let panel_order = get_panel_order(&config); + // Panels should be in insertion order (third, first, second), not alphabetical + // The "no-action" task should be excluded since it has no run/ensure action + assert_eq!(panel_order, vec!["third", "first", "second"]); + } + #[test] fn test_visible_len_empty_panel() { let panel = Panel::new(