From 22ec92723d921e93823d52b7db00e9eb3f9d5f46 Mon Sep 17 00:00:00 2001 From: Vasilito Date: Thu, 7 May 2026 20:57:51 +0100 Subject: [PATCH] feat: build Cub CLI and TUI workflows Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- local/recipes/system/cub/source/Cargo.toml | 1 + .../system/cub/source/cub-cli/Cargo.toml | 7 + .../system/cub/source/cub-cli/src/main.rs | 461 ++++++++++++- .../system/cub/source/cub-lib/src/deps.rs | 22 + .../system/cub/source/cub-lib/src/error.rs | 6 + .../system/cub/source/cub-lib/src/lib.rs | 15 +- .../system/cub/source/cub-tui/src/app.rs | 641 ++++++++++++++++++ .../system/cub/source/cub-tui/src/lib.rs | 84 +++ .../system/cub/source/cub-tui/src/theme.rs | 59 ++ .../cub/source/cub-tui/src/views/build.rs | 27 + .../cub/source/cub-tui/src/views/info.rs | 70 ++ .../cub/source/cub-tui/src/views/install.rs | 27 + .../cub/source/cub-tui/src/views/mod.rs | 67 ++ .../cub/source/cub-tui/src/views/query.rs | 57 ++ .../cub/source/cub-tui/src/views/search.rs | 81 +++ .../cub/source/cub-tui/src/widgets/mod.rs | 37 + 16 files changed, 1633 insertions(+), 29 deletions(-) create mode 100644 local/recipes/system/cub/source/cub-tui/src/app.rs create mode 100644 local/recipes/system/cub/source/cub-tui/src/lib.rs create mode 100644 local/recipes/system/cub/source/cub-tui/src/theme.rs create mode 100644 local/recipes/system/cub/source/cub-tui/src/views/build.rs create mode 100644 local/recipes/system/cub/source/cub-tui/src/views/info.rs create mode 100644 local/recipes/system/cub/source/cub-tui/src/views/install.rs create mode 100644 local/recipes/system/cub/source/cub-tui/src/views/mod.rs create mode 100644 local/recipes/system/cub/source/cub-tui/src/views/query.rs create mode 100644 local/recipes/system/cub/source/cub-tui/src/views/search.rs create mode 100644 local/recipes/system/cub/source/cub-tui/src/widgets/mod.rs diff --git a/local/recipes/system/cub/source/Cargo.toml b/local/recipes/system/cub/source/Cargo.toml index 593f6fc71..09ea3ea3b 100644 --- a/local/recipes/system/cub/source/Cargo.toml +++ b/local/recipes/system/cub/source/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "cub-lib", "cub-cli", + "cub-tui", ] default-members = [ "cub-cli", diff --git a/local/recipes/system/cub/source/cub-cli/Cargo.toml b/local/recipes/system/cub/source/cub-cli/Cargo.toml index d1c102daf..c84048f14 100644 --- a/local/recipes/system/cub/source/cub-cli/Cargo.toml +++ b/local/recipes/system/cub/source/cub-cli/Cargo.toml @@ -13,6 +13,13 @@ path = "src/main.rs" [dependencies] cub-lib = { path = "../cub-lib" } +cub-tui = { path = "../cub-tui", optional = true } redox-pkg = { git = "https://gitlab.redox-os.org/redox-os/pkgutils.git", default-features = false, features = ["indicatif"] } clap = { workspace = true } +pkgar = "0.2.2" +pkgar-core = "0.2.2" termion = "4.0.6" + +[features] +default = ["tui"] +tui = ["cub-tui"] diff --git a/local/recipes/system/cub/source/cub-cli/src/main.rs b/local/recipes/system/cub/source/cub-cli/src/main.rs index fb62b8183..21a013d1a 100644 --- a/local/recipes/system/cub/source/cub-cli/src/main.rs +++ b/local/recipes/system/cub/source/cub-cli/src/main.rs @@ -8,14 +8,20 @@ use std::process::Command; use std::rc::Rc; use std::time::{SystemTime, UNIX_EPOCH}; -use clap::{Parser, Subcommand}; -use cub::converter::{self, ConversionReport, ConversionResult}; +use clap::{CommandFactory, Parser, Subcommand}; +use cub::aur::{AurClient, AurPackage}; +use cub::cook; use cub::error::CubError; +use cub::pkgbuild::{self, ConversionReport, ConversionResult}; use cub::rbpkgbuild::RbPkgBuild; use cub::rbsrcinfo::RbSrcInfo; use cub::sandbox::SandboxConfig; +use cub::storage::CubStore; use pkg::callback::IndicatifCallback; -use pkg::{Library, PackageName}; +use pkg::{Library, PackageName, PackageState}; +use pkgar::ext::EntryExt; +use pkgar::PackageFile; +use pkgar_core::PackageSrc; const DEFAULT_TARGET: &str = "x86_64-unknown-redox"; const HOST_INSTALL_PATH: &str = "/tmp/pkg_install"; @@ -26,6 +32,8 @@ const DEFAULT_BUR_REPO_URL: &str = "https://gitlab.redox-os.org/redox-os/bur.git const DEFAULT_AUR_BASE_URL: &str = "https://aur.archlinux.org"; const PUBLIC_KEY_FILE: &str = "id_ed25519.pub.toml"; const DEFAULT_SECRET_KEY_FILE: &str = "id_ed25519.toml"; +const PACKAGES_HEAD_DIR: &str = "var/lib/packages"; +const AUR_SYNC_STAMP_FILE: &str = "aur-sync.stamp"; struct CookbookAdapter; @@ -42,7 +50,7 @@ struct PkgbuildConverter; impl PkgbuildConverter { fn convert(content: &str) -> Result { - converter::convert_pkgbuild(content) + pkgbuild::convert_pkgbuild(content) } } @@ -66,10 +74,13 @@ impl PackageCreator { #[command(name = "cub")] #[command(version)] #[command(about = "Red Bear OS Package Builder")] -#[command(arg_required_else_help = true)] struct Cli { + /// Disable the default TUI launcher when no subcommand is provided + #[arg(short = 'T', long = "no-tui", global = true)] + no_tui: bool, + #[command(subcommand)] - command: Commands, + command: Option, } #[derive(Debug, Subcommand)] @@ -78,16 +89,32 @@ enum Commands { Install { package: String }, /// Search packages in the official repo and cached BUR Search { query: String }, + /// Show AUR package details + Info { package: String }, + /// Refresh cached package metadata + Sync, + /// Refresh metadata and update installed packages + SystemUpgrade, /// Build and install a local RBPKGBUILD directory Build { dir: String }, /// Fetch a BUR recipe into the current directory Get { package: String }, + /// Import an AUR package into ~/.cub/recipes + GetAur { package: String }, /// Inspect an installed package or local RBPKGBUILD Inspect { target: String }, /// Convert an AUR PKGBUILD into an RBPKGBUILD tree ImportAur { target: String }, /// Update all installed packages UpdateAll, + /// Remove an installed package + Remove { package: String }, + /// List installed packages + QueryLocal, + /// Show installed package details + QueryInfo { package: String }, + /// List files installed by a package + QueryList { package: String }, /// Remove cub and pkg download caches CleanCache, } @@ -143,20 +170,63 @@ fn main() -> Result<(), Box> { let cli = Cli::parse_from(args); let context = AppContext::new(); - match cli.command { - Commands::Install { package } => install_package(&context, &package)?, - Commands::Search { query } => search_packages(&context, &query)?, - Commands::Build { dir } => build_local_dir(&context, Path::new(&dir))?, + if let Some(command) = cli.command { + run_command(&context, command)?; + } else if cli.no_tui { + print_help_text()?; + } else { + launch_tui_or_help()?; + } + + Ok(()) +} + +fn run_command( + context: &AppContext, + command: Commands, +) -> Result<(), Box> { + match command { + Commands::Install { package } => install_package(context, &package)?, + Commands::Search { query } => search_packages(context, &query)?, + Commands::Info { package } => show_aur_info(&package)?, + Commands::Sync => sync_sources()?, + Commands::SystemUpgrade => system_upgrade(context)?, + Commands::Build { dir } => build_local_dir(context, Path::new(&dir))?, Commands::Get { package } => fetch_bur_recipe(&package)?, - Commands::Inspect { target } => inspect_target(&context, &target)?, + Commands::GetAur { package } => get_aur_recipe(&package)?, + Commands::Inspect { target } => inspect_target(context, &target)?, Commands::ImportAur { target } => import_aur_target(&target)?, - Commands::UpdateAll => update_all(&context)?, + Commands::UpdateAll => update_all(context)?, + Commands::Remove { package } => remove_package(context, &package)?, + Commands::QueryLocal => query_local_packages(context)?, + Commands::QueryInfo { package } => query_local_info(context, &package)?, + Commands::QueryList { package } => query_local_files(context, &package)?, Commands::CleanCache => clean_cache()?, } Ok(()) } +fn launch_tui_or_help() -> Result<(), Box> { + #[cfg(feature = "tui")] + { + cub_tui::run()?; + return Ok(()); + } + + #[cfg(not(feature = "tui"))] + { + print_help_text() + } +} + +fn print_help_text() -> Result<(), Box> { + let mut command = Cli::command(); + command.print_help()?; + println!(); + Ok(()) +} + fn rewrite_shortcut_args( args: impl IntoIterator, ) -> Result, Box> { @@ -167,28 +237,77 @@ fn rewrite_shortcut_args( let binary = collected[0].clone(); let rest = &collected[1..]; - let Some(flag) = rest.first().and_then(|value| value.to_str()) else { + let prefix_len = leading_global_flag_count(rest); + let prefix = &rest[..prefix_len]; + let tail = &rest[prefix_len..]; + + let Some(flag) = tail.first().and_then(|value| value.to_str()) else { return Ok(collected); }; + let rewrite_value = |subcommand: &str, value_name: &str| { + if prefix.is_empty() { + rewrite_value_command(binary.clone(), tail, subcommand, value_name) + } else { + rewrite_value_command_with_prefix(binary.clone(), prefix, tail, subcommand, value_name) + } + }; + let rewrite_flag = |subcommand: &str| { + if prefix.is_empty() { + rewrite_flag_command(binary.clone(), tail, subcommand) + } else { + rewrite_flag_command_with_prefix(binary.clone(), prefix, tail, subcommand) + } + }; + match flag { - "-S" => rewrite_value_command(binary, rest, "install", "package"), - "-Ss" => rewrite_value_command(binary, rest, "search", "query"), - "-B" => rewrite_value_command(binary, rest, "build", "dir"), - "-G" => rewrite_value_command(binary, rest, "get", "package"), - "-Pi" => rewrite_value_command(binary, rest, "inspect", "target"), - "--import-aur" => rewrite_value_command(binary, rest, "import-aur", "target"), - "-Sua" => rewrite_flag_command(binary, rest, "update-all"), - "-Sc" => rewrite_flag_command(binary, rest, "clean-cache"), + "-S" => rewrite_value("install", "package"), + "-Ss" => rewrite_value("search", "query"), + "-Si" => rewrite_value("info", "package"), + "-Sy" => rewrite_flag("sync"), + "-Syu" => rewrite_flag("system-upgrade"), + "-B" => rewrite_value("build", "dir"), + "-G" => rewrite_value("get-aur", "package"), + "-R" => rewrite_value("remove", "package"), + "-Q" => rewrite_flag("query-local"), + "-Qi" => rewrite_value("query-info", "package"), + "-Ql" => rewrite_value("query-list", "package"), + "-Pi" => rewrite_value("inspect", "target"), + "--import-aur" => rewrite_value("import-aur", "target"), + "-Sua" => rewrite_flag("update-all"), + "-Sc" => rewrite_flag("clean-cache"), _ => Ok(collected), } } +fn leading_global_flag_count(rest: &[OsString]) -> usize { + let mut count = 0; + while let Some(flag) = rest.get(count).and_then(|value| value.to_str()) { + if matches!(flag, "-T" | "--no-tui") { + count += 1; + } else { + break; + } + } + + count +} + fn rewrite_value_command( binary: OsString, rest: &[OsString], subcommand: &str, value_name: &str, +) -> Result, Box> { + rewrite_value_command_with_prefix(binary, &[], rest, subcommand, value_name) +} + +fn rewrite_value_command_with_prefix( + binary: OsString, + prefix: &[OsString], + rest: &[OsString], + subcommand: &str, + value_name: &str, ) -> Result, Box> { let Some(value) = rest.get(1) else { return Err(io::Error::new( @@ -198,7 +317,10 @@ fn rewrite_value_command( .into()); }; - let mut rewritten = vec![binary, OsString::from(subcommand), value.clone()]; + let mut rewritten = vec![binary]; + rewritten.extend(prefix.iter().cloned()); + rewritten.push(OsString::from(subcommand)); + rewritten.push(value.clone()); rewritten.extend(rest.iter().skip(2).cloned()); Ok(rewritten) } @@ -208,7 +330,18 @@ fn rewrite_flag_command( rest: &[OsString], subcommand: &str, ) -> Result, Box> { - let mut rewritten = vec![binary, OsString::from(subcommand)]; + rewrite_flag_command_with_prefix(binary, &[], rest, subcommand) +} + +fn rewrite_flag_command_with_prefix( + binary: OsString, + prefix: &[OsString], + rest: &[OsString], + subcommand: &str, +) -> Result, Box> { + let mut rewritten = vec![binary]; + rewritten.extend(prefix.iter().cloned()); + rewritten.push(OsString::from(subcommand)); rewritten.extend(rest.iter().skip(1).cloned()); Ok(rewritten) } @@ -274,7 +407,51 @@ fn search_packages(context: &AppContext, query: &str) -> Result<(), Box Result<(), Box> { + let client = AurClient::new(); + let packages = client.info(&[package])?; + let aur_package = packages + .into_iter() + .find(|candidate| candidate.name == package) + .ok_or_else(|| { + CubError::PackageNotFound(format!("{package} not found in AUR info response")) + })?; + + print_aur_package(&aur_package); + Ok(()) +} + +fn sync_sources() -> Result<(), Box> { + let bur_dir = sync_bur_repo()?; + let store = init_cub_store()?; + let aur_client = AurClient::new(); + let sample_results = aur_client.search("a", Some("name"))?; + let stamp_path = store.sources_dir().join(AUR_SYNC_STAMP_FILE); + fs::write( + &stamp_path, + format!( + "synced_at_unix = {}\naur_sample_results = {}\n", + current_unix_timestamp(), + sample_results.len() + ), + )?; + + println!("Refreshed BUR cache at {}.", bur_dir.display()); + println!( + "Verified live AUR metadata access and wrote sync stamp to {}.", + stamp_path.display() + ); + Ok(()) +} + +fn system_upgrade(context: &AppContext) -> Result<(), Box> { + sync_sources()?; + update_all(context) +} + fn build_local_dir(context: &AppContext, dir: &Path) -> Result<(), Box> { + cook::cook_available()?; + let rbpkg_path = dir.join("RBPKGBUILD"); let rbpkg = RbPkgBuild::from_file(&rbpkg_path)?; rbpkg.validate()?; @@ -349,6 +526,60 @@ fn fetch_bur_recipe(package: &str) -> Result<(), Box> { Ok(()) } +fn get_aur_recipe(package: &str) -> Result<(), Box> { + let repo_url = aur_repo_url(package); + let clone_dir = create_temp_dir("cub-aur-get")?; + + let status = Command::new("git") + .arg("clone") + .arg("--depth") + .arg("1") + .arg(&repo_url) + .arg(&clone_dir) + .status()?; + if !status.success() { + return Err(Box::new(CubError::BuildFailed(format!( + "failed to clone AUR source from {repo_url}" + )))); + } + + let pkgbuild_path = clone_dir.join("PKGBUILD"); + let pkgbuild = fs::read_to_string(&pkgbuild_path)?; + let conversion = PkgbuildConverter::convert(&pkgbuild)?; + + let store = init_cub_store()?; + let recipe_path = cub::recipe::save_recipe_to_store(&conversion.rbpkg, &store)?; + let recipe_dir = recipe_path.parent().ok_or_else(|| { + CubError::BuildFailed(format!("invalid saved recipe path {}", recipe_path.display())) + })?; + + fs::create_dir_all(recipe_dir.join("patches"))?; + fs::create_dir_all(recipe_dir.join("import"))?; + fs::write(recipe_dir.join("RBPKGBUILD"), conversion.rbpkg.to_toml()?)?; + fs::write( + recipe_dir.join(".RBSRCINFO"), + RbSrcInfo::from_rbpkgbuild(&conversion.rbpkg).to_string(), + )?; + fs::write(recipe_dir.join("import").join("PKGBUILD"), pkgbuild)?; + + let srcinfo_path = clone_dir.join(".SRCINFO"); + if srcinfo_path.is_file() { + fs::copy(&srcinfo_path, recipe_dir.join("import").join(".SRCINFO"))?; + } + + let report = render_conversion_report(&conversion.report); + fs::write(recipe_dir.join("import").join("report.txt"), &report)?; + + println!( + "Imported AUR package {} into {}.", + conversion.rbpkg.package.name, + recipe_dir.display() + ); + println!("Saved cookbook recipe to {}.", recipe_path.display()); + println!("{report}"); + Ok(()) +} + fn inspect_target(context: &AppContext, target: &str) -> Result<(), Box> { let path = Path::new(target); if path.exists() { @@ -358,7 +589,7 @@ fn inspect_target(context: &AppContext, target: &str) -> Result<(), Box Result<(), Box> { Ok(()) } +fn remove_package(context: &AppContext, package: &str) -> Result<(), Box> { + let package_name = PackageName::new(package.to_string())?; + let mut library = context.open_library()?; + library.uninstall(vec![package_name])?; + let applied = apply_library_changes(&mut library)?; + println!("Removed {} ({} change(s)).", package, applied); + Ok(()) +} + +fn query_local_packages(context: &AppContext) -> Result<(), Box> { + let library = context.open_library()?; + let mut packages = library.get_installed_packages()?; + packages.sort(); + + if packages.is_empty() { + println!("No packages are currently installed."); + return Ok(()); + } + + for package in packages { + println!("{}", package); + } + + Ok(()) +} + +fn query_local_info( + context: &AppContext, + package: &str, +) -> Result<(), Box> { + let mut library = context.open_library()?; + let info = library.info(PackageName::new(package.to_string())?)?; + print_local_package_info(package, &info); + Ok(()) +} + +fn query_local_files( + context: &AppContext, + package: &str, +) -> Result<(), Box> { + let package_name = PackageName::new(package.to_string())?; + let package_state = PackageState::from_sysroot(&context.install_path)?; + let install_state = package_state.installed.get(&package_name).ok_or_else(|| { + CubError::PackageNotFound(format!("{package} is not installed under {}", context.install_path.display())) + })?; + let repo_key = package_state.pubkeys.get(&install_state.remote).ok_or_else(|| { + CubError::BuildFailed(format!( + "missing repository public key '{}' for installed package {}", + install_state.remote, package + )) + })?; + + let head_path = context + .install_path + .join(PACKAGES_HEAD_DIR) + .join(format!("{package}.pkgar_head")); + let mut package_file = PackageFile::new(&head_path, &repo_key.pkey)?; + + let mut paths = Vec::new(); + for entry in package_file.read_entries()? { + let relative = entry.check_path()?; + paths.push(format!("/{}", relative.display())); + } + paths.sort(); + + if paths.is_empty() { + println!("{} has no recorded installed files.", package); + } else { + for path in paths { + println!("{} {}", package, path); + } + } + + Ok(()) +} + fn clean_cache() -> Result<(), Box> { remove_dir_if_exists(Path::new(PKG_DOWNLOAD_DIR))?; remove_dir_if_exists(Path::new(CUB_CACHE_DIR))?; + if let Ok(store) = CubStore::new() { + if let Err(error) = store.cache_clean() { + eprintln!("Failed to clean Cub store sources cache: {error}"); + } + } println!("Removed package caches from {PKG_DOWNLOAD_DIR} and {CUB_CACHE_DIR}."); Ok(()) } @@ -430,6 +742,58 @@ fn apply_library_changes(library: &mut Library) -> Result "yes", + Some(false) => "no", + None => "unknown", + } + ); +} + +fn print_local_package_info(package: &str, info: &pkg::PackageInfo) { + println!("Repository : {}", info.package.remote); + println!("Name : {}", info.package.package.name); + println!("Version : {}", empty_if_blank(&info.package.package.version)); + println!("Target : {}", empty_if_blank(&info.package.package.target)); + println!("Installed : {}", yes_no(info.installed)); + println!("Depends On : {}", join_package_names(&info.package.package.depends)); + println!("Blake3 : {}", empty_if_blank(&info.package.package.blake3)); + println!("Storage Size : {}", info.package.package.storage_size); + println!("Network Size : {}", info.package.package.network_size); + println!( + "Source ID : {}", + empty_if_blank(&info.package.package.source_identifier) + ); + println!( + "Commit ID : {}", + empty_if_blank(&info.package.package.commit_identifier) + ); + println!( + "Published At : {}", + empty_if_blank(&info.package.package.time_identifier) + ); + if info.package.package.name.as_str() != package { + println!("Requested As : {}", package); + } +} + fn inspect_rbpkgbuild_path(path: &Path) -> Result<(), Box> { let rbpkg_path = if path.is_dir() { path.join("RBPKGBUILD") @@ -613,6 +977,12 @@ fn sync_bur_repo() -> Result> { Ok(repo_dir) } +fn init_cub_store() -> Result> { + let store = CubStore::new()?; + store.init()?; + Ok(store) +} + fn default_bur_repo_url() -> String { env::var("CUB_BUR_REPO_URL").unwrap_or_else(|_| DEFAULT_BUR_REPO_URL.to_string()) } @@ -799,3 +1169,46 @@ fn remove_dir_if_exists(path: &Path) -> Result<(), io::Error> { } Ok(()) } + +fn current_unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn join_strings(values: &[String]) -> String { + if values.is_empty() { + "None".to_string() + } else { + values.join(", ") + } +} + +fn join_package_names(values: &[PackageName]) -> String { + if values.is_empty() { + "None".to_string() + } else { + values + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + } +} + +fn empty_if_blank(value: &str) -> &str { + if value.trim().is_empty() { + "None" + } else { + value + } +} + +fn yes_no(value: bool) -> &'static str { + if value { + "yes" + } else { + "no" + } +} diff --git a/local/recipes/system/cub/source/cub-lib/src/deps.rs b/local/recipes/system/cub/source/cub-lib/src/deps.rs index 647c36459..6588f3a38 100644 --- a/local/recipes/system/cub/source/cub-lib/src/deps.rs +++ b/local/recipes/system/cub/source/cub-lib/src/deps.rs @@ -12,8 +12,30 @@ pub fn map_dependency(arch_name: &str) -> MappedDep { let (mapped, is_exact) = match base.as_str() { "glibc" => ("relibc".to_string(), false), "gcc" | "make" => ("build-base".to_string(), false), + "gcc-libs" => ("gcc".to_string(), false), "pkg-config" => ("pkg-config".to_string(), true), + "glib2" => ("glib".to_string(), true), + "gtk3" => ("gtk".to_string(), false), + "gtk4" => ("gtk".to_string(), false), "openssl" => ("openssl3".to_string(), false), + "qt5-base" => ("qtbase".to_string(), false), + "qt6-base" => ("qtbase".to_string(), false), + "sdl2" => ("sdl2".to_string(), true), + "freetype2" => ("freetype".to_string(), true), + "fontconfig" => ("fontconfig".to_string(), true), + "harfbuzz" => ("harfbuzz".to_string(), true), + "libpng" => ("libpng".to_string(), true), + "libjpeg-turbo" => ("libjpeg-turbo".to_string(), true), + "libx11" => ("libx11".to_string(), false), + "libxcb" => ("libxcb".to_string(), false), + "wayland" => ("wayland".to_string(), true), + "wayland-protocols" => ("wayland-protocols".to_string(), true), + "xorg-server" => (String::new(), false), + "xorgproto" => (String::new(), false), + "llvm" => ("llvm".to_string(), true), + "clang" => ("llvm".to_string(), false), + "linux-api-headers" => (String::new(), false), + "linux-firmware" => (String::new(), false), "zlib" => ("zlib".to_string(), true), "libffi" => ("libffi".to_string(), true), "pcre2" => ("pcre2".to_string(), true), diff --git a/local/recipes/system/cub/source/cub-lib/src/error.rs b/local/recipes/system/cub/source/cub-lib/src/error.rs index c8bb269e5..c0f0392fc 100644 --- a/local/recipes/system/cub/source/cub-lib/src/error.rs +++ b/local/recipes/system/cub/source/cub-lib/src/error.rs @@ -18,6 +18,12 @@ pub enum CubError { Conversion(String), #[error("Dependency resolution failed: {0}")] Dependency(String), + #[error("AUR error: {0}")] + Aur(String), + #[error("Storage error: {0}")] + Storage(String), + #[error("Network error: {0}")] + Network(String), #[error("Sandbox error: {0}")] Sandbox(String), } diff --git a/local/recipes/system/cub/source/cub-lib/src/lib.rs b/local/recipes/system/cub/source/cub-lib/src/lib.rs index 3be642d8a..f2bbfab48 100644 --- a/local/recipes/system/cub/source/cub-lib/src/lib.rs +++ b/local/recipes/system/cub/source/cub-lib/src/lib.rs @@ -1,11 +1,16 @@ -pub mod rbpkgbuild; -pub mod rbsrcinfo; -pub mod cookbook; +pub mod aur; pub mod converter; +pub mod cook; +pub mod cookbook; pub mod deps; -pub mod sandbox; +pub mod error; #[cfg(feature = "full")] pub mod package; -pub mod error; +pub mod pkgbuild; +pub mod rbpkgbuild; +pub mod recipe; +pub mod rbsrcinfo; +pub mod sandbox; +pub mod storage; pub use error::CubError; diff --git a/local/recipes/system/cub/source/cub-tui/src/app.rs b/local/recipes/system/cub/source/cub-tui/src/app.rs new file mode 100644 index 000000000..a2bbf6f8c --- /dev/null +++ b/local/recipes/system/cub/source/cub-tui/src/app.rs @@ -0,0 +1,641 @@ +use std::fs; +use std::io::{self, stdout}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::mpsc::{self, Receiver}; +use std::thread; +use std::time::Duration; + +use cub::aur::{self, AurClient}; +use cub::rbpkgbuild::RbPkgBuild; +use cub::storage::CubStore; +use cub::CubError; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::prelude::TermionBackend; +use ratatui::style::Style; +use ratatui::widgets::Block; +use ratatui::Terminal; +use termion::event::Key; +use termion::input::TermRead; +use termion::raw::IntoRawMode; +use termion::screen::IntoAlternateScreen; + +use crate::theme::RedBearTheme; +use crate::views; + +const DEFAULT_TARGET: &str = "x86_64-unknown-redox"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum View { + Search, + PackageInfo, + Install, + Build, + Query, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ActionKind { + Install, + Build, +} + +#[derive(Debug)] +pub(crate) struct QueryEntry { + pub(crate) title: String, + path: PathBuf, + kind: QueryEntryKind, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QueryEntryKind { + Recipe, + Package, +} + +#[derive(Debug)] +struct ActionUpdate { + kind: ActionKind, + success: bool, + summary: String, + lines: Vec, +} + +pub struct CubApp { + pub search_query: String, + pub search_results: Vec, + pub selected_index: usize, + pub current_view: View, + pub status_message: String, + pub running: bool, + pub store: CubStore, + pub aur_client: Option, + query_entries: Vec, + query_details: Vec, + install_log: Vec, + build_log: Vec, + install_running: bool, + build_running: bool, + action_receiver: Option>, + active_action: Option, + tick: usize, +} + +impl CubApp { + pub fn new() -> Result { + let store = CubStore::new()?; + store.init()?; + + let mut app = Self { + search_query: String::new(), + search_results: Vec::new(), + selected_index: 0, + current_view: View::Search, + status_message: "Type a query, press Enter to search AUR, Tab to change views.".into(), + running: true, + store, + aur_client: Some(AurClient::new()), + query_entries: Vec::new(), + query_details: Vec::new(), + install_log: vec![ + "Select a package in Search or Package Info and press i to install.".into(), + ], + build_log: vec![ + "Select a local recipe in Query or a cached package in Info and press b to build." + .into(), + ], + install_running: false, + build_running: false, + action_receiver: None, + active_action: None, + tick: 0, + }; + app.refresh_query_view()?; + Ok(app) + } + + pub fn run(&mut self) -> Result<(), CubError> { + let stdout = stdout(); + let stdout = stdout.into_raw_mode()?; + let stdout = stdout.into_alternate_screen()?; + let backend = TermionBackend::new(stdout); + let mut terminal = Terminal::new(backend).map_err(terminal_error)?; + terminal.clear().map_err(terminal_error)?; + + let mut events = termion::async_stdin().keys(); + let run_result = (|| -> Result<(), CubError> { + while self.running { + self.tick = self.tick.wrapping_add(1); + self.poll_action_updates(); + terminal + .draw(|frame| self.draw(frame)) + .map_err(terminal_error)?; + + if let Some(event) = events.next() { + match event { + Ok(key) => self.handle_key(key), + Err(error) => { + self.status_message = format!("Input error: {error}"); + self.running = false; + } + } + } + + thread::sleep(Duration::from_millis(16)); + } + + Ok(()) + })(); + + let cursor_result = terminal.show_cursor().map_err(terminal_error); + run_result.and(cursor_result) + } + + pub fn draw(&self, frame: &mut ratatui::Frame<'_>) { + let theme = RedBearTheme::default(); + let area = frame.area(); + frame.render_widget( + Block::default().style(Style::default().bg(theme.background).fg(theme.text)), + area, + ); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(8), + Constraint::Length(2), + ]) + .split(area); + + views::render_tabs(frame, layout[0], self, &theme); + + match self.current_view { + View::Search => views::search::render(frame, layout[1], self, &theme), + View::PackageInfo => views::info::render(frame, layout[1], self, &theme), + View::Install => views::install::render(frame, layout[1], self, &theme), + View::Build => views::build::render(frame, layout[1], self, &theme), + View::Query => views::query::render(frame, layout[1], self, &theme), + } + + views::render_status(frame, layout[2], self, &theme); + } + + pub fn handle_key(&mut self, key: Key) { + match key { + Key::Char('q') | Key::Esc => { + self.running = false; + return; + } + Key::Char('/') => { + self.current_view = View::Search; + self.selected_index = self + .selected_index + .min(self.search_results.len().saturating_sub(1)); + self.status_message = "Search focused. Type a query and press Enter.".into(); + return; + } + Key::Char('\t') => { + self.cycle_view(true); + return; + } + Key::BackTab => { + self.cycle_view(false); + return; + } + _ => {} + } + + match self.current_view { + View::Search => views::search::handle_key(self, key), + View::PackageInfo => views::info::handle_key(self, key), + View::Install => views::install::handle_key(self, key), + View::Build => views::build::handle_key(self, key), + View::Query => views::query::handle_key(self, key), + } + } + + pub fn selected_package(&self) -> Option<&aur::AurPackage> { + self.search_results.get(self.selected_index) + } + + pub(crate) fn query_entries(&self) -> &[QueryEntry] { + &self.query_entries + } + + pub(crate) fn query_details(&self) -> &[String] { + &self.query_details + } + + pub(crate) fn install_log(&self) -> &[String] { + &self.install_log + } + + pub(crate) fn build_log(&self) -> &[String] { + &self.build_log + } + + pub(crate) fn install_running(&self) -> bool { + self.install_running + } + + pub(crate) fn build_running(&self) -> bool { + self.build_running + } + + pub(crate) fn spinner_frame(&self) -> char { + const FRAMES: [char; 4] = ['-', '\\', '|', '/']; + FRAMES[self.tick % FRAMES.len()] + } + + pub fn search(&mut self) { + let query = self.search_query.trim(); + if query.is_empty() { + self.status_message = "Search query cannot be empty.".into(); + self.search_results.clear(); + self.selected_index = 0; + return; + } + + let Some(client) = self.aur_client.as_ref() else { + self.status_message = "AUR client is unavailable in this build.".into(); + return; + }; + + match client.search(query, None) { + Ok(results) => { + self.search_results = results; + self.selected_index = 0; + self.status_message = format!( + "Found {} AUR package(s) for {:?}.", + self.search_results.len(), + query + ); + if self.search_results.is_empty() { + self.current_view = View::Search; + } + } + Err(error) => { + self.search_results.clear(); + self.selected_index = 0; + self.status_message = error.to_string(); + } + } + } + + pub fn start_install_selected(&mut self) { + let Some(package) = self.selected_package() else { + self.status_message = "No package selected to install.".into(); + return; + }; + + if self.install_running || self.build_running { + self.status_message = "Another action is already running.".into(); + self.current_view = View::Install; + return; + } + + let package_name = package.name.clone(); + self.current_view = View::Install; + self.install_running = true; + self.install_log = vec![ + format!("Preparing installation for {}", package_name), + format!("Running command: cub install {}", package_name), + ]; + self.status_message = format!("Installing {}...", package_name); + self.spawn_action( + ActionKind::Install, + "cub", + vec!["install".into(), package_name], + ); + } + + pub fn start_build_selected(&mut self) { + if self.install_running || self.build_running { + self.status_message = "Another action is already running.".into(); + self.current_view = View::Build; + return; + } + + let build_target = if self.current_view == View::Query { + self.selected_query_recipe_dir() + } else { + self.selected_package() + .map(|package| self.store.recipes_dir().join(&package.name)) + .filter(|path| path.is_dir()) + }; + + let Some(recipe_dir) = build_target else { + self.current_view = View::Build; + self.build_log = vec![ + "No local recipe is available for the current selection.".into(), + format!( + "Expected imported recipe under {}", + self.store.recipes_dir().display() + ), + ]; + self.status_message = "Build requires an imported local recipe directory.".into(); + return; + }; + + let display = recipe_dir.display().to_string(); + self.current_view = View::Build; + self.build_running = true; + self.build_log = vec![ + format!("Preparing build for {}", display), + format!("Running command: cub build {}", display), + ]; + self.status_message = format!("Building {}...", display); + self.spawn_action(ActionKind::Build, "cub", vec!["build".into(), display]); + } + + pub fn open_selected_info(&mut self) { + if self.selected_package().is_some() { + self.current_view = View::PackageInfo; + self.status_message = "Package info view. Press i to install or b to build.".into(); + } else { + self.status_message = "Select an AUR package first.".into(); + } + } + + pub fn move_selection(&mut self, delta: isize) { + let len = match self.current_view { + View::Search | View::PackageInfo => self.search_results.len(), + View::Query => self.query_entries.len(), + View::Install | View::Build => 0, + }; + if len == 0 { + self.selected_index = 0; + return; + } + + if delta.is_negative() { + let amount = delta.unsigned_abs(); + self.selected_index = self.selected_index.saturating_sub(amount); + } else { + self.selected_index = self + .selected_index + .saturating_add(delta as usize) + .min(len - 1); + } + + if self.current_view == View::Query { + self.refresh_query_details(); + } + } + + pub fn refresh_query_view(&mut self) -> Result<(), CubError> { + let recipe_dirs = self.store.list_recipes()?; + let pkgars = self.store.list_pkgars(DEFAULT_TARGET)?; + + self.query_entries = recipe_dirs + .into_iter() + .map(|path| QueryEntry { + title: file_name_or_display(&path), + path, + kind: QueryEntryKind::Recipe, + }) + .chain(pkgars.into_iter().map(|path| QueryEntry { + title: file_name_or_display(&path), + path, + kind: QueryEntryKind::Package, + })) + .collect(); + + self.selected_index = self + .selected_index + .min(self.query_entries.len().saturating_sub(1)); + self.refresh_query_details(); + Ok(()) + } + + pub fn refresh_query_details(&mut self) { + let Some(entry) = self.query_entries.get(self.selected_index) else { + self.query_details = vec![ + format!("Store root: {}", self.store.root_dir.display()), + "No local recipes or cached pkgars found yet.".into(), + ]; + return; + }; + + self.query_details = match entry.kind { + QueryEntryKind::Recipe => describe_recipe_dir(&entry.path), + QueryEntryKind::Package => describe_pkgar(&entry.path), + }; + } + + fn cycle_view(&mut self, forward: bool) { + self.current_view = match (self.current_view, forward) { + (View::Search, true) => View::PackageInfo, + (View::PackageInfo, true) => View::Install, + (View::Install, true) => View::Build, + (View::Build, true) => View::Query, + (View::Query, true) => View::Search, + (View::Search, false) => View::Query, + (View::PackageInfo, false) => View::Search, + (View::Install, false) => View::PackageInfo, + (View::Build, false) => View::Install, + (View::Query, false) => View::Build, + }; + + if self.current_view == View::Query { + if let Err(error) = self.refresh_query_view() { + self.status_message = error.to_string(); + } + } + } + + fn spawn_action(&mut self, kind: ActionKind, program: &str, args: Vec) { + let (tx, rx) = mpsc::channel(); + let program = program.to_string(); + self.active_action = Some(kind); + self.action_receiver = Some(rx); + + thread::spawn(move || { + let output = Command::new(&program).args(&args).output(); + let update = match output { + Ok(output) => { + let mut lines = vec![format!("Command: {} {}", program, args.join(" "))]; + let stdout_lines = output_string_lines(&output.stdout); + let stderr_lines = output_string_lines(&output.stderr); + if !stdout_lines.is_empty() { + lines.push("stdout:".into()); + lines.extend(stdout_lines); + } + if !stderr_lines.is_empty() { + lines.push("stderr:".into()); + lines.extend(stderr_lines); + } + + let success = output.status.success(); + let summary = if success { + format!("{} completed successfully.", action_label(kind)) + } else { + format!( + "{} failed with status {:?}.", + action_label(kind), + output.status.code() + ) + }; + + ActionUpdate { + kind, + success, + summary, + lines, + } + } + Err(error) => ActionUpdate { + kind, + success: false, + summary: format!("{} failed to start.", action_label(kind)), + lines: vec![format!("Failed to launch {}: {error}", program)], + }, + }; + + let _ = tx.send(update); + }); + } + + fn poll_action_updates(&mut self) { + let Some(receiver) = self.action_receiver.take() else { + return; + }; + + match receiver.try_recv() { + Ok(update) => { + self.active_action = None; + match update.kind { + ActionKind::Install => { + self.install_running = false; + self.install_log = update.lines; + self.current_view = View::Install; + } + ActionKind::Build => { + self.build_running = false; + self.build_log = update.lines; + self.current_view = View::Build; + } + } + self.status_message = update.summary; + if update.success && matches!(update.kind, ActionKind::Build) { + let _ = self.refresh_query_view(); + } + } + Err(mpsc::TryRecvError::Empty) => { + self.action_receiver = Some(receiver); + } + Err(mpsc::TryRecvError::Disconnected) => { + self.active_action = None; + self.install_running = false; + self.build_running = false; + self.status_message = "Background action channel closed unexpectedly.".into(); + } + } + } + + fn selected_query_recipe_dir(&self) -> Option { + self.query_entries + .get(self.selected_index) + .filter(|entry| entry.kind == QueryEntryKind::Recipe) + .map(|entry| entry.path.clone()) + } +} + +fn terminal_error(error: io::Error) -> CubError { + CubError::Io(error) +} + +fn action_label(kind: ActionKind) -> &'static str { + match kind { + ActionKind::Install => "Install", + ActionKind::Build => "Build", + } +} + +fn output_string_lines(bytes: &[u8]) -> Vec { + let output = String::from_utf8_lossy(bytes); + output + .lines() + .map(str::trim_end) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn file_name_or_display(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| path.display().to_string()) +} + +fn describe_recipe_dir(path: &Path) -> Vec { + let rbpkg_path = path.join("RBPKGBUILD"); + if rbpkg_path.is_file() { + match RbPkgBuild::from_file(&rbpkg_path) { + Ok(rbpkg) => { + return vec![ + format!("Recipe: {}", rbpkg.package.name), + format!( + "Version: {}-{}", + rbpkg.package.version, rbpkg.package.release + ), + format!("Path: {}", path.display()), + format!("Description: {}", rbpkg.package.description), + format!("Build deps: {}", join_or_none(&rbpkg.dependencies.build)), + format!( + "Runtime deps: {}", + join_or_none(&rbpkg.dependencies.runtime) + ), + format!( + "Optional deps: {}", + join_or_none(&rbpkg.dependencies.optional) + ), + ]; + } + Err(error) => { + return vec![ + format!("Recipe path: {}", path.display()), + format!("RBPKGBUILD parse error: {error}"), + ]; + } + } + } + + let recipe_toml = path.join("recipe.toml"); + vec![ + format!("Recipe path: {}", path.display()), + format!("RBPKGBUILD: {}", existence_text(&rbpkg_path)), + format!("recipe.toml: {}", existence_text(&recipe_toml)), + ] +} + +fn describe_pkgar(path: &Path) -> Vec { + let mut lines = vec![format!("Package archive: {}", path.display())]; + match fs::metadata(path) { + Ok(metadata) => { + lines.push(format!("Size: {} bytes", metadata.len())); + } + Err(error) => { + lines.push(format!("Failed to read metadata: {error}")); + } + } + lines +} + +fn existence_text(path: &Path) -> &'static str { + if path.exists() { + "present" + } else { + "missing" + } +} + +fn join_or_none(values: &[String]) -> String { + if values.is_empty() { + "none".into() + } else { + values.join(", ") + } +} diff --git a/local/recipes/system/cub/source/cub-tui/src/lib.rs b/local/recipes/system/cub/source/cub-tui/src/lib.rs new file mode 100644 index 000000000..59d499c62 --- /dev/null +++ b/local/recipes/system/cub/source/cub-tui/src/lib.rs @@ -0,0 +1,84 @@ +use std::io::{self, Write}; + +use termion::clear; +use termion::cursor; +use termion::event::Key; +use termion::input::TermRead; +use termion::raw::IntoRawMode; +use termion::screen::IntoAlternateScreen; + +const SHORTCUTS: &[(&str, &str)] = &[ + ("-S ", "Install a package from the official repo or BUR"), + ("-Ss ", "Search the official repo and cached BUR"), + ("-Si ", "Show AUR package information"), + ("-Sy", "Refresh BUR cache and verify AUR metadata access"), + ("-Syu", "Refresh metadata and update installed packages"), + ("-G ", "Import an AUR package into ~/.cub/recipes"), + ("-B ", "Build and install a local RBPKGBUILD directory"), + ("-R ", "Remove an installed package"), + ("-Q", "List installed packages"), + ("-Qi ", "Show installed package details"), + ("-Ql ", "List files installed by a package"), + ("-Sc", "Clean Cub and pkg download caches"), +]; + +const COMMANDS: &[(&str, &str)] = &[ + ("install ", "Install from repo or BUR"), + ("search ", "Search official repo and BUR cache"), + ("info ", "Query AUR metadata"), + ("sync", "Refresh BUR cache and AUR sync stamp"), + ("system-upgrade", "Sync first, then update installed packages"), + ("build ", "Build and install a local recipe tree"), + ("get ", "Copy a BUR recipe into the current directory"), + ("get-aur ", "Convert an AUR PKGBUILD into ~/.cub/recipes"), + ("inspect ", "Inspect a local RBPKGBUILD or package metadata"), + ("import-aur ", "Convert an AUR PKGBUILD into the current directory"), + ("update-all", "Update installed packages"), + ("remove ", "Remove an installed package"), + ("query-local", "List installed packages"), + ("query-info ", "Show installed package details"), + ("query-list ", "List installed package files"), + ("clean-cache", "Remove caches"), +]; + +pub fn run() -> io::Result<()> { + let stdin = io::stdin(); + let stdout = io::stdout().into_raw_mode()?; + let mut screen = stdout.into_alternate_screen()?; + + render(&mut screen)?; + + for key in stdin.keys() { + match key? { + Key::Char('q') | Key::Esc => break, + Key::Char('r') | Key::Ctrl('l') => render(&mut screen)?, + _ => {} + } + } + + write!(screen, "{}{}{}", clear::All, cursor::Goto(1, 1), cursor::Show)?; + screen.flush() +} + +fn render(screen: &mut impl Write) -> io::Result<()> { + write!(screen, "{}{}{}", clear::All, cursor::Goto(1, 1), cursor::Hide)?; + writeln!(screen, "cub — Red Bear OS Package Builder")?; + writeln!(screen)?; + writeln!(screen, "Default TUI mode is active because no subcommand was provided.")?; + writeln!(screen, "Run `cub --no-tui` to see clap help instead.")?; + writeln!(screen)?; + writeln!(screen, "Arch-style shortcuts")?; + writeln!(screen, "-------------------")?; + for (shortcut, description) in SHORTCUTS { + writeln!(screen, " {:<14} {}", shortcut, description)?; + } + writeln!(screen)?; + writeln!(screen, "Named commands")?; + writeln!(screen, "--------------")?; + for (command, description) in COMMANDS { + writeln!(screen, " {:<18} {}", command, description)?; + } + writeln!(screen)?; + writeln!(screen, "Controls: q / Esc = quit, r / Ctrl-L = redraw")?; + screen.flush() +} diff --git a/local/recipes/system/cub/source/cub-tui/src/theme.rs b/local/recipes/system/cub/source/cub-tui/src/theme.rs new file mode 100644 index 000000000..4c6293bfd --- /dev/null +++ b/local/recipes/system/cub/source/cub-tui/src/theme.rs @@ -0,0 +1,59 @@ +use ratatui::style::{Color, Modifier, Style}; + +#[derive(Clone, Copy, Debug)] +pub struct RedBearTheme { + pub background: Color, + pub surface: Color, + pub accent: Color, + pub text: Color, + pub muted: Color, + pub success: Color, + pub warning: Color, + pub error: Color, +} + +impl Default for RedBearTheme { + fn default() -> Self { + Self { + background: Color::Rgb(14, 14, 18), + surface: Color::Rgb(24, 24, 28), + accent: Color::Rgb(181, 36, 48), + text: Color::Rgb(244, 244, 244), + muted: Color::Rgb(170, 170, 176), + success: Color::Rgb(116, 199, 147), + warning: Color::Rgb(255, 191, 87), + error: Color::Rgb(239, 83, 80), + } + } +} + +impl RedBearTheme { + pub fn base_style(self) -> Style { + Style::default().bg(self.background).fg(self.text) + } + + pub fn muted_style(self) -> Style { + Style::default().bg(self.background).fg(self.muted) + } + + pub fn status_style(self) -> Style { + Style::default().bg(self.surface).fg(self.text) + } + + pub fn border_style(self) -> Style { + Style::default().fg(self.muted) + } + + pub fn focused_border_style(self) -> Style { + Style::default() + .fg(self.accent) + .add_modifier(Modifier::BOLD) + } + + pub fn selected_style(self) -> Style { + Style::default() + .bg(self.accent) + .fg(self.text) + .add_modifier(Modifier::BOLD) + } +} diff --git a/local/recipes/system/cub/source/cub-tui/src/views/build.rs b/local/recipes/system/cub/source/cub-tui/src/views/build.rs new file mode 100644 index 000000000..881a6fc93 --- /dev/null +++ b/local/recipes/system/cub/source/cub-tui/src/views/build.rs @@ -0,0 +1,27 @@ +use ratatui::layout::Rect; +use termion::event::Key; + +use crate::app::CubApp; +use crate::theme::RedBearTheme; +use crate::widgets; + +pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) { + let title = if app.build_running() { + format!("Build {} running", app.spinner_frame()) + } else { + "Build Progress".to_string() + }; + let body = widgets::log_paragraph(app.build_log(), &title, theme, true); + frame.render_widget(body, area); +} + +pub fn handle_key(app: &mut CubApp, key: Key) { + match key { + Key::Char('b') => app.start_build_selected(), + Key::Left => { + app.current_view = crate::app::View::PackageInfo; + app.status_message = "Returned to package info.".into(); + } + _ => {} + } +} diff --git a/local/recipes/system/cub/source/cub-tui/src/views/info.rs b/local/recipes/system/cub/source/cub-tui/src/views/info.rs new file mode 100644 index 000000000..64edf481c --- /dev/null +++ b/local/recipes/system/cub/source/cub-tui/src/views/info.rs @@ -0,0 +1,70 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::text::Line; +use ratatui::widgets::{List, ListItem, Paragraph}; +use termion::event::Key; + +use crate::app::CubApp; +use crate::theme::RedBearTheme; +use crate::widgets; + +pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(8), Constraint::Length(2)]) + .split(area); + + let Some(package) = app.selected_package() else { + let empty = Paragraph::new("Select a package in Search to inspect its details.") + .style(theme.base_style()) + .block(widgets::block("Package Info", theme, true)); + frame.render_widget(empty, layout[0]); + return; + }; + + let items = vec![ + ListItem::new(Line::from(format!("Name: {}", package.name))), + ListItem::new(Line::from(format!("Version: {}", package.version))), + ListItem::new(Line::from(format!("Description: {}", package.description))), + ListItem::new(Line::from(format!( + "Depends: {}", + join_or_none(&package.depends) + ))), + ListItem::new(Line::from(format!( + "Make depends: {}", + join_or_none(&package.makedepends) + ))), + ListItem::new(Line::from(format!( + "Optional depends: {}", + join_or_none(&package.optdepends) + ))), + ]; + + let list = List::new(items).block(widgets::block("Package Info", theme, true)); + frame.render_widget(list, layout[0]); + + let hints = Paragraph::new("i install • b build local recipe • ← return to search") + .style(theme.muted_style()); + frame.render_widget(hints, layout[1]); +} + +pub fn handle_key(app: &mut CubApp, key: Key) { + match key { + Key::Left => { + app.current_view = crate::app::View::Search; + app.status_message = "Search view focused.".into(); + } + Key::Up | Key::Char('k') => app.move_selection(-1), + Key::Down | Key::Char('j') => app.move_selection(1), + Key::Char('i') => app.start_install_selected(), + Key::Char('b') => app.start_build_selected(), + _ => {} + } +} + +fn join_or_none(values: &[String]) -> String { + if values.is_empty() { + "none".into() + } else { + values.join(", ") + } +} diff --git a/local/recipes/system/cub/source/cub-tui/src/views/install.rs b/local/recipes/system/cub/source/cub-tui/src/views/install.rs new file mode 100644 index 000000000..700d10a75 --- /dev/null +++ b/local/recipes/system/cub/source/cub-tui/src/views/install.rs @@ -0,0 +1,27 @@ +use ratatui::layout::Rect; +use termion::event::Key; + +use crate::app::CubApp; +use crate::theme::RedBearTheme; +use crate::widgets; + +pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) { + let title = if app.install_running() { + format!("Install {} running", app.spinner_frame()) + } else { + "Install Progress".to_string() + }; + let body = widgets::log_paragraph(app.install_log(), &title, theme, true); + frame.render_widget(body, area); +} + +pub fn handle_key(app: &mut CubApp, key: Key) { + match key { + Key::Char('i') => app.start_install_selected(), + Key::Left => { + app.current_view = crate::app::View::PackageInfo; + app.status_message = "Returned to package info.".into(); + } + _ => {} + } +} diff --git a/local/recipes/system/cub/source/cub-tui/src/views/mod.rs b/local/recipes/system/cub/source/cub-tui/src/views/mod.rs new file mode 100644 index 000000000..40e852ce4 --- /dev/null +++ b/local/recipes/system/cub/source/cub-tui/src/views/mod.rs @@ -0,0 +1,67 @@ +pub mod build; +pub mod info; +pub mod install; +pub mod query; +pub mod search; + +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::{Paragraph, Tabs}; + +use crate::app::{CubApp, View}; +use crate::theme::RedBearTheme; +use crate::widgets; + +pub fn render_tabs(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) { + let titles = [ + View::Search, + View::PackageInfo, + View::Install, + View::Build, + View::Query, + ] + .into_iter() + .map(view_title) + .map(Line::from) + .collect::>(); + + let selected = match app.current_view { + View::Search => 0, + View::PackageInfo => 1, + View::Install => 2, + View::Build => 3, + View::Query => 4, + }; + + let tabs = Tabs::new(titles) + .block(widgets::block("cub-tui", theme, true)) + .style(theme.base_style()) + .highlight_style(theme.selected_style()) + .select(selected) + .divider("|"); + frame.render_widget(tabs, area); +} + +pub fn render_status( + frame: &mut ratatui::Frame<'_>, + area: Rect, + app: &CubApp, + theme: &RedBearTheme, +) { + let text = format!( + "{} [q/esc quit] [Tab views] [/ search]", + app.status_message + ); + let status = Paragraph::new(text).style(theme.status_style()); + frame.render_widget(status, area); +} + +fn view_title(view: View) -> &'static str { + match view { + View::Search => " Search ", + View::PackageInfo => " Info ", + View::Install => " Install ", + View::Build => " Build ", + View::Query => " Query ", + } +} diff --git a/local/recipes/system/cub/source/cub-tui/src/views/query.rs b/local/recipes/system/cub/source/cub-tui/src/views/query.rs new file mode 100644 index 000000000..70689064f --- /dev/null +++ b/local/recipes/system/cub/source/cub-tui/src/views/query.rs @@ -0,0 +1,57 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::widgets::{List, ListItem, ListState}; +use termion::event::Key; + +use crate::app::CubApp; +use crate::theme::RedBearTheme; +use crate::widgets; + +pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) { + let layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(38), Constraint::Percentage(62)]) + .split(area); + + let items = if app.query_entries().is_empty() { + vec![ListItem::new("No local recipes or cached pkgars.")] + } else { + app.query_entries() + .iter() + .map(|entry| ListItem::new(entry.title.clone())) + .collect() + }; + + let mut state = ListState::default(); + if !app.query_entries().is_empty() { + state.select(Some( + app.selected_index + .min(app.query_entries().len().saturating_sub(1)), + )); + } + + let list = List::new(items) + .block(widgets::block("Local Query", theme, true)) + .highlight_style(theme.selected_style()) + .highlight_symbol("» "); + frame.render_stateful_widget(list, layout[0], &mut state); + + let details = widgets::log_paragraph(app.query_details(), "Details", theme, false); + frame.render_widget(details, layout[1]); +} + +pub fn handle_key(app: &mut CubApp, key: Key) { + match key { + Key::Up | Key::Char('k') => app.move_selection(-1), + Key::Down | Key::Char('j') => app.move_selection(1), + Key::Char('r') => match app.refresh_query_view() { + Ok(()) => { + app.status_message = "Refreshed local cub store view.".into(); + } + Err(error) => { + app.status_message = error.to_string(); + } + }, + Key::Char('b') => app.start_build_selected(), + _ => {} + } +} diff --git a/local/recipes/system/cub/source/cub-tui/src/views/search.rs b/local/recipes/system/cub/source/cub-tui/src/views/search.rs new file mode 100644 index 000000000..16f2192e2 --- /dev/null +++ b/local/recipes/system/cub/source/cub-tui/src/views/search.rs @@ -0,0 +1,81 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::text::Line; +use ratatui::widgets::{List, ListItem, ListState, Paragraph}; +use termion::event::Key; + +use crate::app::CubApp; +use crate::theme::RedBearTheme; +use crate::widgets; + +pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(2), + ]) + .split(area); + + let search_bar = Paragraph::new(app.search_query.as_str()) + .style(theme.base_style()) + .block(widgets::block("AUR Search", theme, true)); + frame.render_widget(search_bar, layout[0]); + + let items = if app.search_results.is_empty() { + vec![ListItem::new( + "No AUR results yet. Type a query and press Enter.", + )] + } else { + app.search_results + .iter() + .map(|package| { + let description = if package.description.is_empty() { + "No description" + } else { + package.description.as_str() + }; + ListItem::new(vec![ + Line::from(format!("{} {}", package.name, package.version)), + Line::from(description.to_string()), + ]) + }) + .collect() + }; + + let mut state = ListState::default(); + if !app.search_results.is_empty() { + state.select(Some( + app.selected_index + .min(app.search_results.len().saturating_sub(1)), + )); + } + + let results = List::new(items) + .block(widgets::block("Results", theme, false)) + .highlight_style(theme.selected_style()) + .highlight_symbol("» "); + frame.render_stateful_widget(results, layout[1], &mut state); + + let hints = Paragraph::new("Enter search • ↑/↓ move • i info • I install • b build") + .style(theme.muted_style()); + frame.render_widget(hints, layout[2]); +} + +pub fn handle_key(app: &mut CubApp, key: Key) { + match key { + Key::Char('\n') => app.search(), + Key::Char('i') | Key::Right => app.open_selected_info(), + Key::Char('I') => app.start_install_selected(), + Key::Char('b') => app.start_build_selected(), + Key::Up | Key::Char('k') => app.move_selection(-1), + Key::Down | Key::Char('j') => app.move_selection(1), + Key::Backspace => { + app.search_query.pop(); + } + Key::Char(ch) if !ch.is_control() => { + app.search_query.push(ch); + } + _ => {} + } +} diff --git a/local/recipes/system/cub/source/cub-tui/src/widgets/mod.rs b/local/recipes/system/cub/source/cub-tui/src/widgets/mod.rs new file mode 100644 index 000000000..e147654d5 --- /dev/null +++ b/local/recipes/system/cub/source/cub-tui/src/widgets/mod.rs @@ -0,0 +1,37 @@ +use ratatui::style::Style; +use ratatui::text::Text; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; + +use crate::theme::RedBearTheme; + +pub fn block<'a>(title: &'a str, theme: &RedBearTheme, focused: bool) -> Block<'a> { + let border_style = if focused { + theme.focused_border_style() + } else { + theme.border_style() + }; + + Block::default() + .title(title) + .borders(Borders::ALL) + .style(Style::default().bg(theme.background).fg(theme.text)) + .border_style(border_style) +} + +pub fn log_paragraph<'a>( + lines: &[String], + title: &'a str, + theme: &RedBearTheme, + focused: bool, +) -> Paragraph<'a> { + let body = if lines.is_empty() { + Text::from("No output yet.") + } else { + Text::from(lines.join("\n")) + }; + + Paragraph::new(body) + .wrap(Wrap { trim: false }) + .style(theme.base_style()) + .block(block(title, theme, focused)) +}