From 85cd4c6406c9ade8427daf2bfd6b7c5e8d9f84a1 Mon Sep 17 00:00:00 2001 From: kokopi-dev Date: Sun, 5 Apr 2026 00:36:10 +0900 Subject: [PATCH] update:ui qol --- src/cli.rs | 6 ++-- src/cli/list.rs | 90 +++++++++++++++++++++++++++++++++++++------------ 2 files changed, 72 insertions(+), 24 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 7a6c425..eb6e72a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -9,8 +9,8 @@ use crate::{error::AppError, services::ServiceStore}; #[derive(Clone, Eq, PartialEq)] enum MenuAction { - Add, List, + Add, Edit, Remove, } @@ -19,15 +19,15 @@ pub fn show_main_menu(store: &ServiceStore) -> Result<(), AppError> { intro("scripts-organizer")?; let action = select("What would you like to do?") - .item(MenuAction::Add, "Add", "register a new script") .item(MenuAction::List, "List", "view and run registered scripts") + .item(MenuAction::Add, "Add", "register a new script") .item(MenuAction::Edit, "Edit", "update a registered script's settings") .item(MenuAction::Remove, "Remove", "unregister a script") .interact()?; match action { - MenuAction::Add => add::run(store)?, MenuAction::List => list::run(store)?, + MenuAction::Add => add::run(store)?, MenuAction::Edit => edit::run(store)?, MenuAction::Remove => remove::run(store)?, } diff --git a/src/cli/list.rs b/src/cli/list.rs index d851ab0..065928b 100644 --- a/src/cli/list.rs +++ b/src/cli/list.rs @@ -3,17 +3,25 @@ use std::{os::unix::process::CommandExt, process::Command}; use cliclack::{confirm, log, note, outro, select}; use crate::{ + config::types::ScriptEntry, error::AppError, services::{registry_service::is_healthy, ServiceStore}, }; -/// Health of a registered script's files on disk. #[derive(Clone, Eq, PartialEq)] enum Health { Ok, Broken, } +/// Select value — either a real script name or a group header sentinel. +/// Headers are re-prompted if accidentally selected. +#[derive(Clone, Eq, PartialEq)] +enum Item { + Script(String), + Header(String), +} + pub fn run(store: &ServiceStore) -> Result<(), AppError> { let registry = store.registry.load()?; @@ -25,33 +33,74 @@ pub fn run(store: &ServiceStore) -> Result<(), AppError> { return Ok(()); } - // Annotate each entry with its health before building the select items - let annotated: Vec<(&crate::config::types::ScriptEntry, Health)> = registry + // ── Group by primary tag, sort within each group by name ────────────────── + let mut scripts_with_health: Vec<(&ScriptEntry, Health)> = registry .scripts .iter() - .map(|entry| { - let health = if is_healthy(entry) { Health::Ok } else { Health::Broken }; - (entry, health) + .map(|e| { + let health = if is_healthy(e) { Health::Ok } else { Health::Broken }; + (e, health) }) .collect(); - // ── Script selection ─────────────────────────────────────────────────────── - let selected_name: String = { - let mut prompt = select("Which script?"); - for (entry, health) in &annotated { - let label = match health { - Health::Ok => entry.name.clone(), - Health::Broken => format!("⚠ {}", entry.name), - }; - prompt = prompt.item(entry.name.clone(), label, &entry.description); + scripts_with_health.sort_by(|(a, _), (b, _)| { + let tag_a = a.tags.first().map(String::as_str).unwrap_or("untagged"); + let tag_b = b.tags.first().map(String::as_str).unwrap_or("untagged"); + tag_a.cmp(tag_b).then(a.name.cmp(&b.name)) + }); + + // Collect into (group_label, entries) pairs + let mut groups: Vec<(String, Vec<(&ScriptEntry, Health)>)> = Vec::new(); + for (entry, health) in scripts_with_health { + let group_key = if entry.tags.is_empty() { + "untagged".to_string() + } else { + // Header shows all tags: [git, config] + format!("[{}]", entry.tags.join(", ")) + }; + + match groups.last_mut() { + Some((key, entries)) if key == &group_key => entries.push((entry, health)), + _ => groups.push((group_key, vec![(entry, health)])), + } + } + + // ── Build select — headers + indented script names ───────────────────────── + let selected_name = loop { + let mut prompt = select("Which script?"); + + for (group_label, entries) in &groups { + prompt = prompt.item(Item::Header(group_label.clone()), group_label, ""); + + for (entry, health) in entries { + let name = match health { + Health::Ok => format!(" * {}", entry.name), + Health::Broken => format!(" * ⚠ {}", entry.name), + }; + prompt = prompt.item( + Item::Script(entry.name.clone()), + name, + &entry.description, + ); + } + } + + match prompt.interact()? { + Item::Script(name) => break name, + Item::Header(_) => continue, } - prompt.interact()? }; - let (entry, health) = annotated - .into_iter() + // ── Look up the selected entry ───────────────────────────────────────────── + let (entry, health) = registry + .scripts + .iter() + .map(|e| { + let health = if is_healthy(e) { Health::Ok } else { Health::Broken }; + (e, health) + }) .find(|(e, _)| e.name == selected_name) - .expect("selected name must exist in annotated list"); + .expect("selected name must exist in registry"); // ── Broken entry handling ────────────────────────────────────────────────── if health == Health::Broken { @@ -69,11 +118,10 @@ pub fn run(store: &ServiceStore) -> Result<(), AppError> { return Ok(()); } - // ── Healthy entry: exec shim --help, replacing this process ─────────────── + // ── Healthy: exec shim --help, replacing this process ───────────────────── outro(format!("Running {} --help...", entry.name))?; let err = Command::new(&entry.shim_path).arg("--help").exec(); - // exec() only returns on failure Err(AppError::Io(err)) }