feat: build Cub CLI and TUI workflows
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -3,6 +3,7 @@ resolver = "2"
|
||||
members = [
|
||||
"cub-lib",
|
||||
"cub-cli",
|
||||
"cub-tui",
|
||||
]
|
||||
default-members = [
|
||||
"cub-cli",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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<ConversionResult, CubError> {
|
||||
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<Commands>,
|
||||
}
|
||||
|
||||
#[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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
#[cfg(feature = "tui")]
|
||||
{
|
||||
cub_tui::run()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tui"))]
|
||||
{
|
||||
print_help_text()
|
||||
}
|
||||
}
|
||||
|
||||
fn print_help_text() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut command = Cli::command();
|
||||
command.print_help()?;
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rewrite_shortcut_args(
|
||||
args: impl IntoIterator<Item = OsString>,
|
||||
) -> Result<Vec<OsString>, Box<dyn std::error::Error>> {
|
||||
@@ -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<Vec<OsString>, Box<dyn std::error::Error>> {
|
||||
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<Vec<OsString>, Box<dyn std::error::Error>> {
|
||||
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<Vec<OsString>, Box<dyn std::error::Error>> {
|
||||
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<Vec<OsString>, Box<dyn std::error::Error>> {
|
||||
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<dyn std:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show_aur_info(package: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
sync_sources()?;
|
||||
update_all(context)
|
||||
}
|
||||
|
||||
fn build_local_dir(context: &AppContext, dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_aur_recipe(package: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
let path = Path::new(target);
|
||||
if path.exists() {
|
||||
@@ -358,7 +589,7 @@ fn inspect_target(context: &AppContext, target: &str) -> Result<(), Box<dyn std:
|
||||
|
||||
let mut library = context.open_library()?;
|
||||
let info = library.info(PackageName::new(target.to_string())?)?;
|
||||
println!("{info:#?}");
|
||||
print_local_package_info(target, &info);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -411,9 +642,90 @@ fn update_all(context: &AppContext) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_package(context: &AppContext, package: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<usize, Box<dyn std::er
|
||||
}
|
||||
}
|
||||
|
||||
fn print_aur_package(package: &AurPackage) {
|
||||
println!("Repository : AUR");
|
||||
println!("Name : {}", package.name);
|
||||
println!("Version : {}", package.version);
|
||||
println!("Description : {}", empty_if_blank(&package.description));
|
||||
println!("URL : {}", empty_if_blank(&package.url));
|
||||
println!("Licenses : {}", join_strings(&package.license));
|
||||
println!("Depends On : {}", join_strings(&package.depends));
|
||||
println!("Make Depends : {}", join_strings(&package.makedepends));
|
||||
println!("Optional Deps : {}", join_strings(&package.optdepends));
|
||||
println!("Provides : {}", join_strings(&package.provides));
|
||||
println!("Conflicts With : {}", join_strings(&package.conflicts));
|
||||
println!("Votes : {}", package.num_votes);
|
||||
println!("Popularity : {:.5}", package.popularity);
|
||||
println!("Last Modified : {}", package.last_modified);
|
||||
println!(
|
||||
"Out Of Date : {}",
|
||||
match package.out_of_date {
|
||||
Some(true) => "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<dyn std::error::Error>> {
|
||||
let rbpkg_path = if path.is_dir() {
|
||||
path.join("RBPKGBUILD")
|
||||
@@ -613,6 +977,12 @@ fn sync_bur_repo() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
Ok(repo_dir)
|
||||
}
|
||||
|
||||
fn init_cub_store() -> Result<CubStore, Box<dyn std::error::Error>> {
|
||||
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::<Vec<_>>()
|
||||
.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
pub struct CubApp {
|
||||
pub search_query: String,
|
||||
pub search_results: Vec<aur::AurPackage>,
|
||||
pub selected_index: usize,
|
||||
pub current_view: View,
|
||||
pub status_message: String,
|
||||
pub running: bool,
|
||||
pub store: CubStore,
|
||||
pub aur_client: Option<AurClient>,
|
||||
query_entries: Vec<QueryEntry>,
|
||||
query_details: Vec<String>,
|
||||
install_log: Vec<String>,
|
||||
build_log: Vec<String>,
|
||||
install_running: bool,
|
||||
build_running: bool,
|
||||
action_receiver: Option<Receiver<ActionUpdate>>,
|
||||
active_action: Option<ActionKind>,
|
||||
tick: usize,
|
||||
}
|
||||
|
||||
impl CubApp {
|
||||
pub fn new() -> Result<Self, CubError> {
|
||||
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<String>) {
|
||||
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<PathBuf> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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(", ")
|
||||
}
|
||||
}
|
||||
@@ -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 <pkg>", "Install a package from the official repo or BUR"),
|
||||
("-Ss <query>", "Search the official repo and cached BUR"),
|
||||
("-Si <pkg>", "Show AUR package information"),
|
||||
("-Sy", "Refresh BUR cache and verify AUR metadata access"),
|
||||
("-Syu", "Refresh metadata and update installed packages"),
|
||||
("-G <pkg>", "Import an AUR package into ~/.cub/recipes"),
|
||||
("-B <dir>", "Build and install a local RBPKGBUILD directory"),
|
||||
("-R <pkg>", "Remove an installed package"),
|
||||
("-Q", "List installed packages"),
|
||||
("-Qi <pkg>", "Show installed package details"),
|
||||
("-Ql <pkg>", "List files installed by a package"),
|
||||
("-Sc", "Clean Cub and pkg download caches"),
|
||||
];
|
||||
|
||||
const COMMANDS: &[(&str, &str)] = &[
|
||||
("install <pkg>", "Install from repo or BUR"),
|
||||
("search <query>", "Search official repo and BUR cache"),
|
||||
("info <pkg>", "Query AUR metadata"),
|
||||
("sync", "Refresh BUR cache and AUR sync stamp"),
|
||||
("system-upgrade", "Sync first, then update installed packages"),
|
||||
("build <dir>", "Build and install a local recipe tree"),
|
||||
("get <pkg>", "Copy a BUR recipe into the current directory"),
|
||||
("get-aur <pkg>", "Convert an AUR PKGBUILD into ~/.cub/recipes"),
|
||||
("inspect <target>", "Inspect a local RBPKGBUILD or package metadata"),
|
||||
("import-aur <target>", "Convert an AUR PKGBUILD into the current directory"),
|
||||
("update-all", "Update installed packages"),
|
||||
("remove <pkg>", "Remove an installed package"),
|
||||
("query-local", "List installed packages"),
|
||||
("query-info <pkg>", "Show installed package details"),
|
||||
("query-list <pkg>", "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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -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(", ")
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -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::<Vec<_>>();
|
||||
|
||||
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 ",
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user