diff --git a/Cargo.lock b/Cargo.lock index dfd0b44..4ddbdc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anyhow" version = "1.0.100" @@ -55,12 +61,41 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "console" version = "0.15.11" @@ -82,6 +117,31 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -92,6 +152,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -102,12 +196,24 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encode_unicode" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -124,6 +230,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "futures-core" version = "0.3.31" @@ -136,6 +248,15 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -158,12 +279,44 @@ dependencies = [ "wasip2", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "insta" version = "1.46.0" @@ -176,6 +329,28 @@ dependencies = [ "tempfile", ] +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -245,6 +420,12 @@ version = "0.2.179" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -266,6 +447,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "matchers" version = "0.2.0" @@ -288,6 +478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -301,6 +492,7 @@ dependencies = [ "hex", "insta", "jiff", + "nocontrol-tui", "rand", "serde", "serde_json", @@ -313,6 +505,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "nocontrol-tui" +version = "0.1.0" +dependencies = [ + "anyhow", + "crossterm", + "fuzzy-matcher", + "nocontrol", + "ratatui", + "serde_json", + "tokio", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -351,6 +556,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -434,6 +645,27 @@ dependencies = [ "getrandom", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -460,6 +692,19 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.3" @@ -469,7 +714,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -479,6 +724,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + [[package]] name = "scopeguard" version = "1.2.0" @@ -548,6 +799,27 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -580,6 +852,40 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "syn" version = "2.0.113" @@ -600,7 +906,7 @@ dependencies = [ "fastrand", "getrandom", "once_cell", - "rustix", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -749,6 +1055,35 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "uuid" version = "1.19.0" @@ -833,6 +1168,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/crates/nocontrol-tui/Cargo.toml b/crates/nocontrol-tui/Cargo.toml new file mode 100644 index 0000000..b5cb693 --- /dev/null +++ b/crates/nocontrol-tui/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "nocontrol-tui" +version = "0.1.0" +edition = "2021" + +[lib] +name = "nocontrol_tui" +path = "src/lib.rs" + +[dependencies] +nocontrol = { workspace = true } + +anyhow = { workspace = true } +tokio = { workspace = true } + +serde_json = "1.0" + +# TUI dependencies +ratatui = "0.29" +crossterm = "0.28" +fuzzy-matcher = "0.3" diff --git a/crates/nocontrol-tui/src/lib.rs b/crates/nocontrol-tui/src/lib.rs new file mode 100644 index 0000000..7367bd0 --- /dev/null +++ b/crates/nocontrol-tui/src/lib.rs @@ -0,0 +1,647 @@ +use std::io; +use std::sync::Arc; +use std::time::Duration; + +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use fuzzy_matcher::FuzzyMatcher; +use fuzzy_matcher::skim::SkimMatcherV2; +use nocontrol::{manifests::ManifestState, ControlPlane, Operator, Specification}; +use ratatui::{ + backend::{Backend, CrosstermBackend}, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, + Frame, Terminal, +}; +use tokio::sync::Mutex; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InputMode { + Normal, + Search, +} + +struct App { + control_plane: ControlPlane, + manifests: Vec>, + filtered_indices: Vec, + list_state: ListState, + + input_mode: InputMode, + command_input: String, + search_query: String, + + command_history: Vec, + history_index: Option, + + messages: Vec, + should_quit: bool, + + fuzzy_matcher: SkimMatcherV2, +} + +impl App { + fn new(control_plane: ControlPlane) -> Self { + let mut list_state = ListState::default(); + list_state.select(Some(0)); + + Self { + control_plane, + manifests: Vec::new(), + filtered_indices: Vec::new(), + list_state, + + input_mode: InputMode::Normal, + command_input: String::new(), + search_query: String::new(), + + command_history: Vec::new(), + history_index: None, + + messages: vec![ + "Welcome to nocontrol TUI".to_string(), + "Press / to search, type commands, ↑↓ for history/navigation".to_string(), + ], + should_quit: false, + + fuzzy_matcher: SkimMatcherV2::default(), + } + } + + async fn refresh_manifests(&mut self) { + if let Ok(manifests) = self.control_plane.get_manifests().await { + self.manifests = manifests; + self.update_filtered_list(); + } + } + + fn update_filtered_list(&mut self) { + if self.input_mode == InputMode::Search && !self.search_query.is_empty() { + // Fuzzy search through manifest names and specs + let mut scored_indices: Vec<(usize, i64)> = self + .manifests + .iter() + .enumerate() + .filter_map(|(idx, manifest)| { + let search_text = format!( + "{} {:?} {:?}", + manifest.manifest.name, + manifest.status.status, + manifest.manifest.spec.kind() + ); + + self.fuzzy_matcher + .fuzzy_match(&search_text, &self.search_query) + .map(|score| (idx, score)) + }) + .collect(); + + // Sort by score (highest first) + scored_indices.sort_by(|a, b| b.1.cmp(&a.1)); + + self.filtered_indices = scored_indices.into_iter().map(|(idx, _)| idx).collect(); + } else { + // No filtering, show all manifests + self.filtered_indices = (0..self.manifests.len()).collect(); + } + + // Reset selection to first item if list changed + if !self.filtered_indices.is_empty() { + self.list_state.select(Some(0)); + } else { + self.list_state.select(None); + } + } + + fn get_selected_manifest(&self) -> Option<&ManifestState> { + self.list_state + .selected() + .and_then(|selected_idx| self.filtered_indices.get(selected_idx)) + .and_then(|&manifest_idx| self.manifests.get(manifest_idx)) + } + + fn next(&mut self) { + if self.filtered_indices.is_empty() { + return; + } + let i = match self.list_state.selected() { + Some(i) => { + if i >= self.filtered_indices.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.list_state.select(Some(i)); + } + + fn previous(&mut self) { + if self.filtered_indices.is_empty() { + return; + } + let i = match self.list_state.selected() { + Some(i) => { + if i == 0 { + self.filtered_indices.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + self.list_state.select(Some(i)); + } + + fn history_previous(&mut self) { + if self.command_history.is_empty() { + return; + } + + let new_index = match self.history_index { + None => Some(self.command_history.len() - 1), + Some(0) => Some(0), // Stay at oldest + Some(i) => Some(i - 1), + }; + + if let Some(idx) = new_index { + self.history_index = Some(idx); + self.command_input = self.command_history[idx].clone(); + } + } + + fn history_next(&mut self) { + if self.command_history.is_empty() { + return; + } + + let new_index = match self.history_index { + None => None, + Some(i) if i >= self.command_history.len() - 1 => { + // Clear input when going past newest + self.command_input.clear(); + None + } + Some(i) => Some(i + 1), + }; + + self.history_index = new_index; + if let Some(idx) = new_index { + self.command_input = self.command_history[idx].clone(); + } + } + + fn toggle_search_mode(&mut self) { + self.input_mode = match self.input_mode { + InputMode::Normal => { + self.search_query.clear(); + InputMode::Search + } + InputMode::Search => { + self.search_query.clear(); + self.update_filtered_list(); + InputMode::Normal + } + }; + } + + fn update_search(&mut self) { + if self.input_mode == InputMode::Search { + self.search_query = self.command_input.clone(); + self.update_filtered_list(); + } + } + + async fn execute_command(&mut self) { + let cmd = self.command_input.trim().to_string(); + if cmd.is_empty() { + return; + } + + // Add to history + if self.command_history.last() != Some(&cmd) { + self.command_history.push(cmd.clone()); + } + self.history_index = None; + + self.messages.push(format!("> {}", cmd)); + + let parts: Vec<&str> = cmd.split_whitespace().collect(); + match parts.as_slice() { + ["get"] | ["list"] => { + self.refresh_manifests().await; + self.messages.push(format!( + "Found {} manifest(s), showing {}", + self.manifests.len(), + self.filtered_indices.len() + )); + } + ["describe"] => { + if let Some(manifest) = self.get_selected_manifest() { + let json = serde_json::to_string_pretty(manifest).unwrap_or_default(); + self.messages.push(json); + } else { + self.messages.push("No manifest selected".to_string()); + } + } + ["quit"] | ["exit"] | ["q"] => { + self.should_quit = true; + } + ["clear"] => { + self.messages.clear(); + self.messages.push("Screen cleared".to_string()); + } + ["history"] => { + self.messages.push("Command history:".to_string()); + for (i, cmd) in self.command_history.iter().enumerate() { + self.messages.push(format!(" {}: {}", i + 1, cmd)); + } + } + ["help"] => { + self.messages.push("Commands:".to_string()); + self.messages.push(" get, list - Refresh manifest list".to_string()); + self.messages.push(" describe - Show selected manifest details".to_string()); + self.messages.push(" history - Show command history".to_string()); + self.messages.push(" clear - Clear output messages".to_string()); + self.messages.push(" quit, exit, q - Exit application".to_string()); + self.messages.push(" help - Show this help".to_string()); + self.messages.push("".to_string()); + self.messages.push("Keys:".to_string()); + self.messages.push(" / - Toggle search mode".to_string()); + self.messages.push(" ↑/↓ - Navigate list OR cycle command history".to_string()); + self.messages.push(" Enter - Execute command (or exit search)".to_string()); + self.messages.push(" Esc - Clear input / exit search".to_string()); + self.messages.push(" q - Quick quit (when input empty)".to_string()); + } + _ => { + self.messages.push(format!("Unknown command: {}. Type 'help' for commands.", cmd)); + } + } + + self.command_input.clear(); + + // Keep only last 100 messages + if self.messages.len() > 100 { + self.messages.drain(0..self.messages.len() - 100); + } + } +} + +async fn run_app( + terminal: &mut Terminal, + app: Arc>>, +) -> anyhow::Result<()> +where + TOperator::Specifications: Send + Sync, +{ + // Spawn refresh task + let app_clone = app.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_millis(500)).await; + app_clone.lock().await.refresh_manifests().await; + } + }); + + loop { + // Draw UI + { + let mut app = app.lock().await; + terminal.draw(|f| ui(f, &mut app))?; + + if app.should_quit { + break; + } + } + + // Handle input with timeout + if event::poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + let mut app = app.lock().await; + + match (key.code, key.modifiers, app.input_mode) { + // Quick quit with 'q' when input is empty + (KeyCode::Char('q'), KeyModifiers::NONE, InputMode::Normal) + if app.command_input.is_empty() => { + app.should_quit = true; + } + + // Toggle search mode with '/' + (KeyCode::Char('/'), KeyModifiers::NONE, _) => { + app.toggle_search_mode(); + } + + // Character input + (KeyCode::Char(c), _, _) => { + app.command_input.push(c); + app.update_search(); + } + + // Backspace + (KeyCode::Backspace, _, _) => { + app.command_input.pop(); + app.update_search(); + } + + // Enter key + (KeyCode::Enter, _, InputMode::Search) => { + // Exit search mode but keep filter + app.input_mode = InputMode::Normal; + app.command_input.clear(); + } + (KeyCode::Enter, _, InputMode::Normal) => { + app.execute_command().await; + } + + // Arrow keys + (KeyCode::Up, _, InputMode::Normal) if !app.command_input.is_empty() => { + // Navigate history when typing a command + app.history_previous(); + } + (KeyCode::Down, _, InputMode::Normal) if !app.command_input.is_empty() => { + // Navigate history when typing a command + app.history_next(); + } + (KeyCode::Up, _, _) => { + // Navigate manifest list + app.previous(); + } + (KeyCode::Down, _, _) => { + // Navigate manifest list + app.next(); + } + + // Escape key + (KeyCode::Esc, _, InputMode::Search) => { + app.toggle_search_mode(); + app.command_input.clear(); + } + (KeyCode::Esc, _, InputMode::Normal) => { + app.command_input.clear(); + app.history_index = None; + } + + _ => {} + } + } + } + } + } + + Ok(()) +} + +fn ui(f: &mut Frame, app: &mut App) { + // Create layout + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title + Constraint::Min(10), // Main content + Constraint::Length(10), // Messages + Constraint::Length(3), // Command input + ]) + .split(f.area()); + + // Title + let mode_text = match app.input_mode { + InputMode::Normal => "NORMAL", + InputMode::Search => "SEARCH", + }; + let title = Paragraph::new(vec![ + Line::from(vec![ + Span::styled( + "NoControl - Kubernetes-like Control Plane", + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + format!("[{}]", mode_text), + Style::default() + .fg(if app.input_mode == InputMode::Search { Color::Yellow } else { Color::Green }) + .add_modifier(Modifier::BOLD), + ), + ]), + ]) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(title, chunks[0]); + + // Main content area - split into list and details + let main_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) + .split(chunks[1]); + + // Manifest list + render_manifest_list(f, app, main_chunks[0]); + + // Manifest details + render_manifest_details(f, app, main_chunks[1]); + + // Messages area + render_messages(f, app, chunks[2]); + + // Command input + render_command_input(f, app, chunks[3]); +} + +fn render_manifest_list(f: &mut Frame, app: &mut App, area: Rect) { + // Collect filtered manifests data before borrowing list_state + let filtered_data: Vec<_> = app.filtered_indices + .iter() + .filter_map(|&idx| app.manifests.get(idx)) + .map(|m| (m.manifest.name.clone(), m.status.status.clone())) + .collect(); + + let items: Vec = filtered_data + .iter() + .map(|(name, status)| { + let status_color = match status { + nocontrol::manifests::ManifestStatusState::Running => Color::Green, + nocontrol::manifests::ManifestStatusState::Started => Color::Yellow, + nocontrol::manifests::ManifestStatusState::Pending => Color::Gray, + nocontrol::manifests::ManifestStatusState::Stopping => Color::Magenta, + nocontrol::manifests::ManifestStatusState::Deleting => Color::Red, + }; + + let status_text = format!("{:?}", status); + let content = Line::from(vec![ + Span::styled( + "● ", + Style::default().fg(status_color), + ), + Span::raw(name), + Span::raw(" "), + Span::styled( + format!("[{}]", status_text), + Style::default().fg(status_color), + ), + ]); + ListItem::new(content) + }) + .collect(); + + let title = if app.input_mode == InputMode::Search && !app.search_query.is_empty() { + format!( + " Manifests ({}/{}) - Filtered ", + filtered_data.len(), + app.manifests.len() + ) + } else { + format!(" Manifests ({}) ", app.manifests.len()) + }; + + let list = List::new(items) + .block(Block::default().title(title).borders(Borders::ALL)) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + + f.render_stateful_widget(list, area, &mut app.list_state); +} + +fn render_manifest_details(f: &mut Frame, app: &App, area: Rect) { + let content = if let Some(manifest) = app.get_selected_manifest() { + let mut lines = vec![ + Line::from(vec![ + Span::styled("Name: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&manifest.manifest.name), + ]), + Line::from(vec![ + Span::styled("Kind: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(manifest.manifest.spec.kind()), + ]), + Line::from(vec![ + Span::styled("Status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{:?}", manifest.status.status)), + ]), + Line::from(vec![ + Span::styled("Generation: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", manifest.generation)), + ]), + Line::from(vec![ + Span::styled("Created: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", manifest.created)), + ]), + Line::from(vec![ + Span::styled("Updated: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", manifest.updated)), + ]), + ]; + + if !manifest.status.events.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled("Events:", Style::default().add_modifier(Modifier::BOLD)))); + for event in manifest.status.events.iter().rev().take(5) { + lines.push(Line::from(format!(" • {}", event.message))); + } + } + + if !manifest.status.changes.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled("Changes:", Style::default().add_modifier(Modifier::BOLD)))); + for change in manifest.status.changes.iter().rev().take(5) { + lines.push(Line::from(format!( + " • {:?} at {}", + change.event, change.created + ))); + } + } + + Text::from(lines) + } else { + Text::from("No manifest selected") + }; + + let paragraph = Paragraph::new(content) + .block(Block::default().title(" Details ").borders(Borders::ALL)) + .wrap(Wrap { trim: true }); + + f.render_widget(paragraph, area); +} + +fn render_messages(f: &mut Frame, app: &App, area: Rect) { + let messages: Vec = app + .messages + .iter() + .rev() + .take(8) + .rev() + .map(|m| Line::from(m.clone())) + .collect(); + + let paragraph = Paragraph::new(messages) + .block(Block::default().title(" Output ").borders(Borders::ALL)) + .wrap(Wrap { trim: false }); + + f.render_widget(paragraph, area); +} + +fn render_command_input(f: &mut Frame, app: &App, area: Rect) { + let (title, input_text, style) = match app.input_mode { + InputMode::Normal => { + let hist_info = if let Some(idx) = app.history_index { + format!(" [History {}/{}]", idx + 1, app.command_history.len()) + } else { + String::new() + }; + ( + format!(" Command{} (/ to search, ↑↓ for history, 'help' for commands) ", hist_info), + app.command_input.as_str(), + Style::default().fg(Color::Yellow), + ) + } + InputMode::Search => ( + " Search (fuzzy) - Enter to apply, Esc to cancel ".to_string(), + app.command_input.as_str(), + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ), + }; + + let input = Paragraph::new(input_text) + .style(style) + .block(Block::default().title(title).borders(Borders::ALL)); + + f.render_widget(input, area); +} + +/// Run the TUI with the given control plane +pub async fn run(control_plane: ControlPlane) -> anyhow::Result<()> +where + TOperator: Operator + Send + Sync + 'static, + TOperator::Specifications: Send + Sync, +{ + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Create app state + let app = Arc::new(Mutex::new(App::new(control_plane))); + + // Run TUI + let res = run_app(&mut terminal, app).await; + + // Restore terminal + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + res +} diff --git a/crates/nocontrol/Cargo.toml b/crates/nocontrol/Cargo.toml index 61ed663..82dcd00 100644 --- a/crates/nocontrol/Cargo.toml +++ b/crates/nocontrol/Cargo.toml @@ -18,6 +18,7 @@ tracing.workspace = true uuid = { version = "1.19.0", features = ["serde", "v4", "v7"] } [dev-dependencies] +nocontrol-tui = { path = "../nocontrol-tui" } insta = "1.46.0" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } tracing-test = { version = "0.2.5", features = ["no-env-filter"] } diff --git a/crates/nocontrol/examples/kubernetes-like/main.rs b/crates/nocontrol/examples/kubernetes-like/main.rs index 5f8a999..9814077 100644 --- a/crates/nocontrol/examples/kubernetes-like/main.rs +++ b/crates/nocontrol/examples/kubernetes-like/main.rs @@ -1,4 +1,4 @@ -use std::io::{BufRead, Write}; +use std::time::Duration; use async_trait::async_trait; use nocontrol::{ @@ -10,10 +10,10 @@ use tracing_subscriber::EnvFilter; #[tokio::main] async fn main() -> anyhow::Result<()> { + // Setup logging to file let output_file = std::fs::File::create("target/nocontrol.log")?; tracing_subscriber::fmt() - // .pretty() .with_env_filter(EnvFilter::from_default_env()) .with_writer(output_file) .with_file(false) @@ -23,109 +23,57 @@ async fn main() -> anyhow::Result<()> { .init(); let operator = MyOperator {}; + let control_plane = nocontrol::ControlPlane::new(operator); - let mut control_plane = nocontrol::ControlPlane::new(operator); - // control_plane.with_deadline(std::time::Duration::from_secs(4)); + // Add initial manifest + control_plane + .add_manifest(Manifest { + name: "initial-deployment".into(), + metadata: ManifestMetadata {}, + spec: Specifications::Deployment(DeploymentControllerManifest { + name: "initial-app".into(), + }), + }) + .await?; + // Spawn random manifest updater tokio::spawn({ let control_plane = control_plane.clone(); async move { - control_plane - .add_manifest(Manifest { - name: "some-manifest".into(), - metadata: ManifestMetadata {}, - spec: Specifications::Deployment(DeploymentControllerManifest { - name: "some-name".into(), - }), - }) - .await - .unwrap(); - loop { let rand = { - use rand::prelude::*; + use rand::Rng; let mut rng = rand::rng(); - rng.random_range(2..5) + rng.random_range(3..8) }; - tokio::time::sleep(std::time::Duration::from_secs(rand)).await; + tokio::time::sleep(Duration::from_secs(rand)).await; let random = uuid::Uuid::now_v7(); - control_plane + let _ = control_plane .add_manifest(Manifest { - name: "some-manifest".into(), + name: "initial-deployment".into(), metadata: ManifestMetadata {}, spec: Specifications::Deployment(DeploymentControllerManifest { - name: format!("some-changed-name: {}", random), + name: format!("app-{}", &random.to_string()[..8]), }), }) - .await - .unwrap(); + .await; } } }); - // Debugging shell + // Spawn control plane tokio::spawn({ let control_plane = control_plane.clone(); - async move { - let ui = Ui {}; - - loop { - ui.write("> "); - let cmd = ui.read_line(); - - let items = cmd.split(" ").map(|t| t.to_string()).collect::>(); - - let (command, args) = match &items[..] { - [first, rest @ ..] => (first, rest.to_vec()), - //[first] => (first, vec![]), - _ => { - ui.writeln("invalid command"); - continue; - } - }; - - match (command.as_str(), args.as_slice()) { - ("get", _) => { - // get all for now - let manifests = control_plane - .get_manifests() - .await - .inspect_err(|e| ui.writeln(format!("get failed: {e:#}"))) - .unwrap(); - - ui.writeln("listing manifests"); - - for manifest in manifests { - ui.writeln(format!(" - {}", manifest.manifest.name)); - } - } - ("describe", [manifest_name, ..]) => { - let manifests = control_plane - .get_manifests() - .await - .inspect_err(|e| ui.writeln(format!("get failed: {e:#}"))) - .unwrap(); - - if let Some(manifest) = - manifests.iter().find(|m| &m.manifest.name == manifest_name) - { - let output = serde_json::to_string_pretty(&manifest).unwrap(); - ui.writeln(output); - } - } - (cmd, _) => ui.writeln(format!("command is not implemented: {}", cmd)), - } - - ui.writeln(""); - } + let _ = control_plane.execute().await; } }); - control_plane.execute().await?; + // Run TUI + nocontrol_tui::run(control_plane).await?; Ok(()) } @@ -180,29 +128,3 @@ impl Specification for Specifications { pub struct DeploymentControllerManifest { name: String, } - -pub struct Ui {} - -impl Ui { - pub fn write(&self, msg: &str) { - let mut stderr = std::io::stderr().lock(); - stderr.write_all(msg.as_bytes()).unwrap(); - stderr.flush().unwrap() - } - pub fn writeln(&self, msg: impl AsRef) { - let msg = msg.as_ref(); - - let mut stderr = std::io::stderr().lock(); - stderr.write_all(msg.as_bytes()).unwrap(); - writeln!(stderr).unwrap(); - stderr.flush().unwrap() - } - pub fn read_line(&self) -> String { - let mut stdin = std::io::stdin().lock(); - let mut output = String::new(); - - stdin.read_line(&mut output).unwrap(); - - output.trim().to_string() - } -} diff --git a/crates/nocontrol/src/control_plane/backing_store.rs b/crates/nocontrol/src/control_plane/backing_store.rs index 5b016be..8248e4b 100644 --- a/crates/nocontrol/src/control_plane/backing_store.rs +++ b/crates/nocontrol/src/control_plane/backing_store.rs @@ -122,6 +122,7 @@ impl BackingStore { events: Vec::default(), changes: vec![ManifestChangeEvent { created: now, + handled: false, event: ManifestChangeEventType::Changed, }], }, diff --git a/crates/nocontrol/src/control_plane/reconciler.rs b/crates/nocontrol/src/control_plane/reconciler.rs index be14627..3fba8fe 100644 --- a/crates/nocontrol/src/control_plane/reconciler.rs +++ b/crates/nocontrol/src/control_plane/reconciler.rs @@ -60,11 +60,22 @@ impl Reconciler { } // 4. Check desired vs actual - for manifest in our_manifests.iter_mut() { + 'manifest: for manifest in our_manifests.iter_mut() { // Currently periodic sync, // TODO: this should also be made event based + + if let Some(change) = manifest.status.changes.first() { + if change.handled { + continue 'manifest; + } + } + self.operator.reconcile(manifest).await?; self.store.update_state(manifest).await?; + + if let Some(change) = manifest.status.changes.first_mut() { + change.handled = true + } } tokio::time::sleep(std::time::Duration::from_millis(500)).await; diff --git a/crates/nocontrol/src/manifests.rs b/crates/nocontrol/src/manifests.rs index ada0b60..16c89b1 100644 --- a/crates/nocontrol/src/manifests.rs +++ b/crates/nocontrol/src/manifests.rs @@ -58,6 +58,7 @@ pub enum ManifestStatusState { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ManifestChangeEvent { pub created: jiff::Timestamp, + pub handled: bool, pub event: ManifestChangeEventType, }