diff --git a/Cargo.lock b/Cargo.lock index 36fae34a..ad35a5f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -420,6 +420,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + [[package]] name = "memoffset" version = "0.6.5" @@ -644,6 +650,38 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "serde" +version = "1.0.205" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e33aedb1a7135da52b7c21791455563facbbcc43d0f0f66165b42c21b3dfb150" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.205" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692d6f5ac90220161d6774db30c662202721e64aed9058d2c394f451261420c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784b6203951c57ff748476b126ccb5e8e2959a5c19e5c617ab1956be3dbc68da" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "serial" version = "0.4.0" @@ -844,6 +882,8 @@ dependencies = [ "oneshot", "portable-pty", "ratatui", + "serde", + "serde_json", "tempdir", "tui-term", ] diff --git a/Cargo.toml b/Cargo.toml index cacbdcb3..a5d4df3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,8 @@ ratatui = "0.27.0" tui-term = "0.1.12" include_dir = "0.7.4" tempdir = "0.3.7" +serde_json = "1.0.122" +serde = { version = "1.0.205", features = ["derive"] } [[bin]] name = "linutil" diff --git a/src/running_command.rs b/src/running_command.rs index d07c0428..ec76db88 100644 --- a/src/running_command.rs +++ b/src/running_command.rs @@ -13,7 +13,7 @@ use ratatui::{ }; use std::{ io::Write, - path::Path, + path::{Path, PathBuf}, sync::{Arc, Mutex}, thread::JoinHandle, }; @@ -22,10 +22,10 @@ use tui_term::{ widget::PseudoTerminal, }; -#[derive(Clone)] +#[derive(Clone, Hash, Eq, PartialEq)] pub enum Command { - Raw(&'static str), - LocalFile(&'static str), + Raw(String), + LocalFile(PathBuf), None, // Directory } diff --git a/src/state.rs b/src/state.rs index 5f8ef1a6..1c07da13 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,7 +2,7 @@ use crate::{ float::{Float, FloatContent}, floating_text::FloatingText, running_command::{Command, RunningCommand}, - tabs::{ListNode, TABS}, + tabs::{ListNode, Tab}, theme::Theme, }; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; @@ -23,6 +23,8 @@ pub struct AppState { temp_path: PathBuf, /// Currently focused area focus: Focus, + /// List of tabs + tabs: Vec, /// Current tab current_tab: ListState, /// Current search query @@ -52,11 +54,13 @@ struct ListEntry { impl AppState { pub fn new(theme: Theme, temp_path: PathBuf) -> Self { - let root_id = TABS[0].tree.root().id(); + let tabs = crate::tabs::get_tabs(&temp_path, true); + let root_id = tabs[0].tree.root().id(); let mut state = Self { theme, temp_path, focus: Focus::List, + tabs, current_tab: ListState::default().with_selected(Some(0)), search_query: String::new(), items: vec![], @@ -67,7 +71,8 @@ impl AppState { state } pub fn draw(&mut self, frame: &mut Frame) { - let longest_tab_display_len = TABS + let longest_tab_display_len = self + .tabs .iter() .map(|tab| tab.name.len() + self.theme.tab_icon.len()) .max() @@ -85,7 +90,11 @@ impl AppState { .constraints([Constraint::Length(3), Constraint::Min(1)]) .split(horizontal[0]); - let tabs = TABS.iter().map(|tab| tab.name).collect::>(); + let tabs = self + .tabs + .iter() + .map(|tab| tab.name.as_str()) + .collect::>(); let tab_hl_style = if let Focus::TabList = self.focus { Style::default().reversed().fg(self.theme.tab_color) @@ -186,7 +195,7 @@ impl AppState { self.focus = Focus::List } KeyCode::Char('j') | KeyCode::Down - if self.current_tab.selected().unwrap() + 1 < TABS.len() => + if self.current_tab.selected().unwrap() + 1 < self.tabs.len() => { self.current_tab.select_next(); self.refresh_tab(); @@ -220,7 +229,7 @@ impl AppState { } pub fn update_items(&mut self) { if self.search_query.is_empty() { - let curr = TABS[self.current_tab.selected().unwrap()] + let curr = self.tabs[self.current_tab.selected().unwrap()] .tree .get(*self.visit_stack.last().unwrap()) .unwrap(); @@ -237,7 +246,7 @@ impl AppState { self.items.clear(); let query_lower = self.search_query.to_lowercase(); - for tab in TABS.iter() { + for tab in self.tabs.iter() { let mut stack = vec![tab.tree.root().id()]; while let Some(node_id) = stack.pop() { let node = tab.tree.get(node_id).unwrap(); @@ -255,7 +264,7 @@ impl AppState { stack.extend(node.children().map(|child| child.id())); } } - self.items.sort_by(|a, b| a.node.name.cmp(b.node.name)); + self.items.sort_by(|a, b| a.node.name.cmp(&b.node.name)); } } /// Checks ehther the current tree node is the root node (can we go up the tree or no) @@ -319,7 +328,10 @@ impl AppState { self.update_items(); } fn refresh_tab(&mut self) { - self.visit_stack = vec![TABS[self.current_tab.selected().unwrap()].tree.root().id()]; + self.visit_stack = vec![self.tabs[self.current_tab.selected().unwrap()] + .tree + .root() + .id()]; self.selection.select(Some(0)); self.update_items(); } diff --git a/src/tabs.rs b/src/tabs.rs index e7ef5d35..a01b5fa2 100644 --- a/src/tabs.rs +++ b/src/tabs.rs @@ -1,162 +1,179 @@ -use std::sync::LazyLock; - -use ego_tree::{tree, Tree}; - use crate::running_command::Command; +use ego_tree::{tree, NodeId, Tree}; +use serde::Deserialize; +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, + sync::LazyLock, +}; +#[derive(Deserialize)] +struct ScriptInfo { + ui_path: Vec, + #[serde(default)] + description: String, + #[serde(default)] + preconditions: Option>, + #[serde(default)] + command: Option, +} + +impl ScriptInfo { + fn is_supported(&self) -> bool { + self.preconditions.as_deref().map_or(true, |preconditions| { + preconditions.iter().all( + |Precondition { + matches, + data, + values, + }| { + match data { + SystemDataType::Environment(var_name) => std::env::var(var_name) + .map_or(false, |var| values.contains(&var) == *matches), + SystemDataType::File(path) => { + std::fs::read_to_string(path).map_or(false, |data| { + values + .iter() + .any(|matching_value| data.contains(matching_value)) + == *matches + }) + } + } + }, + ) + }) + } +} + +#[derive(Deserialize)] +struct Precondition { + // If true, the data must be contained within the list of values. + // Otherwise, the data must not be contained within the list of values + matches: bool, + data: SystemDataType, + values: Vec, +} + +#[derive(Deserialize)] +enum SystemDataType { + #[serde(rename = "environment")] + Environment(String), + #[serde(rename = "file")] + File(PathBuf), +} + +#[derive(Hash, Eq, PartialEq)] pub struct Tab { - pub name: &'static str, + pub name: String, pub tree: Tree, } -#[derive(Clone)] +#[derive(Clone, Hash, Eq, PartialEq)] pub struct ListNode { - pub name: &'static str, + pub name: String, pub command: Command, } -pub static TABS: LazyLock> = LazyLock::new(|| { - vec![ - Tab { - name: "System Setup", - tree: tree!(ListNode { - name: "root", - command: Command::None, - } => { - ListNode { - name: "Full System Update", - command: Command::LocalFile("system-update.sh"), - }, - ListNode { - name: "Build Prerequisites", - command: Command::LocalFile("system-setup/1-compile-setup.sh"), - }, - ListNode { - name: "Gaming Dependencies", - command: Command::LocalFile("system-setup/2-gaming-setup.sh"), - }, - ListNode { - name: "Global Theme", - command: Command::LocalFile("system-setup/3-global-theme.sh"), - }, - ListNode { - name: "Remove Snaps", - command: Command::LocalFile("system-setup/4-remove-snaps.sh"), +pub fn get_tabs(command_dir: &Path, validate: bool) -> Vec { + let scripts = get_script_list(command_dir); + + let mut paths: HashMap, (String, NodeId)> = HashMap::new(); + let mut tabs: Vec = Vec::new(); + + for (json_file, script) in scripts { + let json_text = std::fs::read_to_string(&json_file).unwrap(); + let script_info: ScriptInfo = + serde_json::from_str(&json_text).expect("Unexpected JSON input"); + if validate && !script_info.is_supported() { + continue; + } + if script_info.ui_path.len() < 2 { + panic!( + "UI path must contain a tab. Ensure that {} has correct data", + json_file.display() + ); + } + let command = match script_info.command { + Some(command) => Command::Raw(command), + None if script.exists() => Command::LocalFile(script), + _ => panic!( + "Command not specified & matching script does not exist for JSON {}", + json_file.display() + ), + }; + for path_index in 1..script_info.ui_path.len() { + let path = script_info.ui_path[..path_index].to_vec(); + if !paths.contains_key(&path) { + let tab_name = script_info.ui_path[0].clone(); + if path_index == 1 { + let tab = Tab { + name: tab_name.clone(), + tree: Tree::new(ListNode { + name: "root".to_string(), + command: Command::None, + }), + }; + let root_id = tab.tree.root().id(); + tabs.push(tab); + paths.insert(path, (tab_name, root_id)); + } else { + let parent_path = &script_info.ui_path[..path_index - 1]; + let (tab, parent_id) = paths.get(parent_path).unwrap(); + let tab = tabs + .iter_mut() + .find(|Tab { name, .. }| name == tab) + .unwrap(); + let mut parent = tab.tree.get_mut(*parent_id).unwrap(); + let new_node = ListNode { + name: script_info.ui_path[path_index - 1].clone(), + command: Command::None, + }; + let new_id = parent.append(new_node).id(); + paths.insert(path, (tab_name, new_id)); } - }), - }, - Tab { - name: "Applications Setup", - tree: tree!(ListNode { - name: "root", - command: Command::None, - } => { - ListNode { - name: "Alacritty", - command: Command::LocalFile("applications-setup/alacritty-setup.sh"), - }, - ListNode { - name: "Bash Prompt", - command: Command::Raw("bash -c \"$(curl -s https://raw.githubusercontent.com/ChrisTitusTech/mybash/main/setup.sh)\""), - }, - ListNode { - name: "DWM-Titus", - command: Command::LocalFile("applications-setup/dwmtitus-setup.sh") - }, - ListNode { - name: "Kitty", - command: Command::LocalFile("applications-setup/kitty-setup.sh") - }, - ListNode { - name: "Neovim", - command: Command::Raw("bash -c \"$(curl -s https://raw.githubusercontent.com/ChrisTitusTech/neovim/main/setup.sh)\""), - }, - ListNode { - name: "Rofi", - command: Command::LocalFile("applications-setup/rofi-setup.sh"), - }, - ListNode { - name: "ZSH Prompt", - command: Command::LocalFile("applications-setup/zsh-setup.sh"), - } - }), - }, - Tab { - name: "Security", - tree: tree!(ListNode { - name: "root", - command: Command::None, - } => { - ListNode { - name: "Firewall Baselines (CTT)", - command: Command::LocalFile("security/firewall-baselines.sh"), - } - }), - }, - Tab { - name: "Utilities", - tree: tree!(ListNode { - name: "root", - command: Command::None, - } => { - ListNode { - name: "Wifi Manager", - command: Command::LocalFile("utils/wifi-control.sh"), - }, - ListNode { - name: "Bluetooth Manager", - command: Command::LocalFile("utils/bluetooth-control.sh"), - }, - ListNode { - name: "MonitorControl(xorg)", - command: Command::None, - } => { - ListNode { - name: "Set Resolution", - command: Command::LocalFile("utils/monitor-control/set_resolutions.sh"), - }, - ListNode { - name: "Duplicate Displays", - command: Command::LocalFile("utils/monitor-control/duplicate_displays.sh"), - }, - ListNode { - name: "Extend Displays", - command: Command::LocalFile("utils/monitor-control/extend_displays.sh"), - }, - ListNode { - name: "Auto Detect Displays", - command: Command::LocalFile("utils/monitor-control/auto_detect_displays.sh"), - }, - ListNode { - name: "Enable Monitor", - command: Command::LocalFile("utils/monitor-control/enable_monitor.sh"), - }, - ListNode { - name: "Disable Monitor", - command: Command::LocalFile("utils/monitor-control/disable_monitor.sh"), - }, - ListNode { - name: "Set Primary Monitor", - command: Command::LocalFile("utils/monitor-control/set_primary_monitor.sh"), - }, - ListNode { - name: "Change Orientation", - command: Command::LocalFile("utils/monitor-control/change_orientation.sh"), - }, - ListNode { - name: "Manage Arrangement", - command: Command::LocalFile("utils/monitor-control/manage_arrangement.sh"), - }, - ListNode { - name: "Scale Monitors", - command: Command::LocalFile("utils/monitor-control/scale_monitor.sh"), - }, - ListNode { - name: "Reset Scaling", - command: Command::LocalFile("utils/monitor-control/reset_scaling.sh"), - } - }, - }), - }, - ] -}); + } + } + let (tab, parent_id) = paths + .get(&script_info.ui_path[..script_info.ui_path.len() - 1]) + .unwrap(); + let tab = tabs + .iter_mut() + .find(|Tab { name, .. }| name == tab) + .unwrap(); + let mut parent = tab.tree.get_mut(*parent_id).unwrap(); + + let command = ListNode { + name: script_info.ui_path.last().unwrap().clone(), + command, + }; + parent.append(command); + } + if tabs.is_empty() { + panic!("No tabs found."); + } + tabs +} + +fn get_script_list(directory: &Path) -> Vec<(PathBuf, PathBuf)> { + let mut entries = std::fs::read_dir(directory) + .expect("Command directory does not exist.") + .flatten() + .collect::>(); + entries.sort_by_key(|d| d.path()); + + entries + .into_iter() + .filter_map(|entry| { + let path = entry.path(); + // Recursively iterate through directories + if entry.file_type().map_or(false, |f| f.is_dir()) { + Some(get_script_list(&path)) + } else { + let is_json = path.extension().map_or(false, |ext| ext == "json"); + let script = path.with_extension("sh"); + (is_json).then_some(vec![(path, script)]) + } + }) + .flatten() + .collect() +}