mirror of
https://gitlab.redox-os.org/redox-os/redox.git
synced 2026-06-25 22:34:18 +08:00
1757 lines
62 KiB
Rust
1757 lines
62 KiB
Rust
use ansi_to_tui::IntoText;
|
|
use anyhow::{Context, anyhow, bail};
|
|
use cookbook::config::{CookConfig, get_config, init_config};
|
|
use cookbook::cook::cook_build::build;
|
|
use cookbook::cook::fetch::{fetch, fetch_offline};
|
|
use cookbook::cook::fs::{create_target_dir, run_command};
|
|
use cookbook::cook::package::{package, package_target};
|
|
use cookbook::cook::pty::{PtyOut, UnixSlavePty, flush_pty, setup_pty};
|
|
use cookbook::cook::script::KILL_ALL_PID;
|
|
use cookbook::cook::tree::{WalkTreeEntry, display_tree_entry, format_size, walk_tree_entry};
|
|
use cookbook::log_to_pty;
|
|
use cookbook::recipe::CookRecipe;
|
|
use pkg::PackageName;
|
|
use pkg::package::PackageError;
|
|
use ratatui::Terminal;
|
|
use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
|
|
use ratatui::prelude::TermionBackend;
|
|
use ratatui::style::{Color, Style};
|
|
use ratatui::text::{Line, Span, Text};
|
|
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
|
|
use redox_installer::PackageConfig;
|
|
use std::borrow::Cow;
|
|
use std::collections::{HashMap, HashSet, VecDeque};
|
|
use std::io::{Read, Write, stderr, stdin, stdout};
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use std::str::FromStr;
|
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
|
use std::sync::{Arc, OnceLock, mpsc};
|
|
use std::time::{Duration, Instant};
|
|
use std::{cmp, env, fs};
|
|
use std::{process, thread};
|
|
use termion::event::{Event, Key, MouseEvent};
|
|
use termion::input::TermRead;
|
|
use termion::raw::IntoRawMode;
|
|
use termion::screen::IntoAlternateScreen;
|
|
use termion::{color, style};
|
|
|
|
// A repo manager, to replace repo.sh
|
|
|
|
const REPO_HELP_STR: &str = r#"
|
|
Usage: repo <command> [flags] <recipe1> <recipe2> ...
|
|
|
|
command list:
|
|
fetch download recipe sources
|
|
cook build recipe packages
|
|
unfetch delete recipe sources
|
|
clean delete recipe artifacts
|
|
push extract package into sysroot
|
|
find find path of recipe packages
|
|
tree show tree of recipe packages
|
|
|
|
common flags:
|
|
--cookbook=<cookbook_dir> the "recipes" folder, default to $PWD/recipes
|
|
--repo=<repo_dir> the "repo" folder, default to $PWD/repo
|
|
--sysroot=<sysroot_dir> the "root" folder used for "push" command
|
|
For Redox, defaults to "/", else default to $PWD/sysroot
|
|
--with-package-deps include package deps
|
|
--all apply to all recipes in <cookbook_dir>
|
|
--category=<category> apply to all recipes in <cookbook_dir>/<category>
|
|
--filesystem=<filesystem> override recipes config using installer file
|
|
--repo-binary override recipes config to use repo_binary
|
|
|
|
cook env and their defaults:
|
|
CI= set to any value to disable TUI
|
|
COOKBOOK_LOGS= whether to capture build logs (default is !CI)
|
|
COOKBOOK_OFFLINE=false prevent internet access if possible
|
|
ignored when command "fetch" is used
|
|
COOKBOOK_NONSTOP=false pkeep running even a recipe build failed
|
|
COOKBOOK_VERBOSE=true print success/error on each recipe
|
|
COOKBOOK_MAKE_JOBS= override build jobs count from nproc
|
|
"#;
|
|
|
|
#[derive(Clone)]
|
|
struct CliConfig {
|
|
cookbook_dir: PathBuf,
|
|
repo_dir: PathBuf,
|
|
sysroot_dir: PathBuf,
|
|
logs_dir: Option<PathBuf>,
|
|
category: Option<PathBuf>,
|
|
filesystem: Option<redox_installer::Config>,
|
|
with_package_deps: bool,
|
|
all: bool,
|
|
cook: CookConfig,
|
|
}
|
|
|
|
#[derive(PartialEq)]
|
|
enum CliCommand {
|
|
Fetch,
|
|
Cook,
|
|
Unfetch,
|
|
Clean,
|
|
Push,
|
|
Tree,
|
|
Find,
|
|
}
|
|
|
|
impl CliCommand {
|
|
pub fn is_informational(&self) -> bool {
|
|
*self == CliCommand::Tree || *self == CliCommand::Find
|
|
}
|
|
pub fn is_building(&self) -> bool {
|
|
*self == CliCommand::Fetch || *self == CliCommand::Cook
|
|
}
|
|
pub fn is_pushing(&self) -> bool {
|
|
*self == CliCommand::Push || *self == CliCommand::Tree
|
|
}
|
|
pub fn is_cleaning(&self) -> bool {
|
|
*self == CliCommand::Clean || *self == CliCommand::Unfetch
|
|
}
|
|
}
|
|
|
|
impl FromStr for CliCommand {
|
|
type Err = anyhow::Error;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
match s {
|
|
"fetch" => Ok(CliCommand::Fetch),
|
|
"cook" => Ok(CliCommand::Cook),
|
|
"unfetch" => Ok(CliCommand::Unfetch),
|
|
"clean" => Ok(CliCommand::Clean),
|
|
"push" => Ok(CliCommand::Push),
|
|
"tree" => Ok(CliCommand::Tree),
|
|
"find" => Ok(CliCommand::Find),
|
|
_ => Err(anyhow!("Unknown command '{}'\n{}\n", s, REPO_HELP_STR)),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ToString for CliCommand {
|
|
fn to_string(&self) -> String {
|
|
match self {
|
|
CliCommand::Fetch => "fetch".to_string(),
|
|
CliCommand::Cook => "cook".to_string(),
|
|
CliCommand::Unfetch => "unfetch".to_string(),
|
|
CliCommand::Clean => "clean".to_string(),
|
|
CliCommand::Push => "push".to_string(),
|
|
CliCommand::Tree => "tree".to_string(),
|
|
CliCommand::Find => "find".to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CliConfig {
|
|
fn new() -> Result<Self, std::io::Error> {
|
|
let current_dir = env::current_dir()?;
|
|
Ok(CliConfig {
|
|
//FIXME: This config is unused as redox-pkg harcoded this to $PWD/recipes
|
|
cookbook_dir: current_dir.join("recipes"),
|
|
repo_dir: current_dir.join("repo"),
|
|
// build dir here is hardcoded in repo_builder as well
|
|
logs_dir: if get_config().cook.logs {
|
|
Some(current_dir.join("build/logs"))
|
|
} else {
|
|
None
|
|
},
|
|
category: None,
|
|
sysroot_dir: if cfg!(target_os = "redox") {
|
|
PathBuf::from("/")
|
|
} else {
|
|
current_dir.join("sysroot")
|
|
},
|
|
with_package_deps: false,
|
|
cook: get_config().cook.clone(),
|
|
all: false,
|
|
filesystem: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
init_config();
|
|
if let Err(e) = main_inner() {
|
|
eprintln!("{:?}", e);
|
|
process::exit(1);
|
|
};
|
|
}
|
|
|
|
fn main_inner() -> anyhow::Result<()> {
|
|
let args: Vec<String> = env::args().skip(1).collect();
|
|
|
|
if args.is_empty() || args.contains(&"--help".to_string()) || args.contains(&"-h".to_string()) {
|
|
println!("{}", REPO_HELP_STR);
|
|
process::exit(1);
|
|
}
|
|
|
|
let (config, command, recipe_names) = parse_args(args)?;
|
|
if command == CliCommand::Cook && config.cook.tui {
|
|
if let Some((name, e)) = run_tui_cook(config.clone(), recipe_names.clone())? {
|
|
let _ = stderr().write(e.as_bytes());
|
|
let _ = stderr().write(b"\n\n");
|
|
print_failed(&command, &name);
|
|
return Err(anyhow!("Execution has failed"));
|
|
} else {
|
|
print_success(&command, None);
|
|
}
|
|
return publish_packages(&recipe_names, &config.repo_dir);
|
|
}
|
|
if command == CliCommand::Tree {
|
|
return handle_tree(&recipe_names, &config);
|
|
}
|
|
if command == CliCommand::Push {
|
|
return handle_push(&recipe_names, &config);
|
|
}
|
|
|
|
let verbose = config.cook.verbose;
|
|
for recipe in &recipe_names {
|
|
match repo_inner(&config, &command, recipe) {
|
|
Ok(_) => {
|
|
print_success(&command, Some(&recipe.name));
|
|
}
|
|
Err(e) => {
|
|
if config.cook.nonstop && verbose {
|
|
eprintln!("{:?}", e);
|
|
}
|
|
print_failed(&command, &recipe.name);
|
|
if !config.cook.nonstop {
|
|
return Err(e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if command == CliCommand::Cook {
|
|
return publish_packages(&recipe_names, &config.repo_dir);
|
|
}
|
|
|
|
if verbose {
|
|
println!(
|
|
"\nCommand '{}' completed for {} recipes.",
|
|
command.to_string(),
|
|
recipe_names.len()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn print_failed(command: &CliCommand, recipe: &PackageName) {
|
|
eprintln!(
|
|
"{}{}{} {} - failed {}{}",
|
|
style::Bold,
|
|
color::Fg(color::AnsiValue(196)),
|
|
command.to_string(),
|
|
recipe.as_str(),
|
|
color::Fg(color::Reset),
|
|
style::Reset,
|
|
);
|
|
}
|
|
|
|
fn print_success(command: &CliCommand, recipe: Option<&PackageName>) {
|
|
if let Some(recipe) = recipe {
|
|
eprintln!(
|
|
"{}{}{} {} - successful{}{}",
|
|
style::Bold,
|
|
color::Fg(color::AnsiValue(46)),
|
|
command.to_string(),
|
|
recipe.as_str(),
|
|
color::Fg(color::Reset),
|
|
style::Reset,
|
|
);
|
|
} else {
|
|
eprintln!(
|
|
"{}{}{} - successful{}{}",
|
|
style::Bold,
|
|
color::Fg(color::AnsiValue(46)),
|
|
command.to_string(),
|
|
color::Fg(color::Reset),
|
|
style::Reset,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn repo_inner(
|
|
config: &CliConfig,
|
|
command: &CliCommand,
|
|
recipe: &CookRecipe,
|
|
) -> Result<(), anyhow::Error> {
|
|
Ok(match *command {
|
|
CliCommand::Fetch | CliCommand::Cook => {
|
|
let repo_inner_fn = move |logger: &PtyOut| -> Result<(), anyhow::Error> {
|
|
let is_cook = *command == CliCommand::Cook;
|
|
let source_dir = handle_fetch(recipe, config, is_cook, logger)?;
|
|
if is_cook {
|
|
handle_cook(recipe, config, source_dir, recipe.is_deps, logger)?;
|
|
}
|
|
Ok(())
|
|
};
|
|
let Some(log_path) = &config.logs_dir else {
|
|
return repo_inner_fn(&None);
|
|
};
|
|
|
|
let (status_tx, status_rx) = mpsc::channel::<StatusUpdate>();
|
|
let (mut stdout_writer, mut stderr_writer) = setup_logger(&status_tx, &recipe.name);
|
|
let mut app = TuiApp::new(vec![recipe.clone()]);
|
|
app.dump_logs_anyway = config.cook.verbose || !config.cook.nonstop;
|
|
let dump_fail_logs = !app.dump_logs_anyway;
|
|
let th = thread::spawn(move || {
|
|
while let Ok(update) = status_rx.recv() {
|
|
match &update {
|
|
StatusUpdate::CookThreadFinished => break,
|
|
StatusUpdate::FailCook(r, _) => {
|
|
let (logs, line) = app.get_recipe_log(&r.name);
|
|
if let Some(logs) = logs {
|
|
println!("{}", join_logs(logs, line));
|
|
}
|
|
}
|
|
_ => app.update_status(update),
|
|
}
|
|
}
|
|
});
|
|
let mut logger = Some((&mut stdout_writer, &mut stderr_writer));
|
|
let result = repo_inner_fn(&logger);
|
|
if let Err(err_ctx) = &result {
|
|
log_to_pty!(&logger, "\n{:?}", err_ctx)
|
|
}
|
|
// successful fetch is not that useful to log
|
|
if *command == CliCommand::Cook || result.is_err() {
|
|
flush_pty(&mut logger);
|
|
let log_path =
|
|
log_path.join(format!("{}/{}.log", recipe.target, recipe.name.name()));
|
|
status_tx
|
|
.send(StatusUpdate::FlushLog(recipe.name.clone(), log_path))
|
|
.unwrap_or_default();
|
|
if dump_fail_logs && result.is_err() {
|
|
status_tx
|
|
.send(StatusUpdate::FailCook(recipe.clone(), "".into()))
|
|
.unwrap_or_default();
|
|
}
|
|
}
|
|
status_tx
|
|
.send(StatusUpdate::CookThreadFinished)
|
|
.unwrap_or_default();
|
|
let _ = th.join();
|
|
result?;
|
|
}
|
|
CliCommand::Unfetch => handle_clean(recipe, config, true, true)?,
|
|
CliCommand::Clean => handle_clean(recipe, config, false, true)?,
|
|
CliCommand::Push => unreachable!(),
|
|
CliCommand::Tree => unreachable!(),
|
|
CliCommand::Find => println!("{}", recipe.dir.display()),
|
|
})
|
|
}
|
|
|
|
fn publish_packages(recipe_names: &Vec<CookRecipe>, repo_path: &PathBuf) -> anyhow::Result<()> {
|
|
let repo_bin = env::current_exe()?.parent().unwrap().join("repo_builder");
|
|
let mut command = Command::new(repo_bin);
|
|
command
|
|
.arg(repo_path)
|
|
.args(recipe_names.iter().filter_map(|n| {
|
|
if !n.is_deps {
|
|
Some(n.name.as_str())
|
|
} else {
|
|
None
|
|
}
|
|
}));
|
|
|
|
run_command(command, &None).map_err(|e| anyhow!(e))
|
|
}
|
|
|
|
fn parse_args(args: Vec<String>) -> anyhow::Result<(CliConfig, CliCommand, Vec<CookRecipe>)> {
|
|
let mut config = CliConfig::new()?;
|
|
let mut command: Option<String> = None;
|
|
let mut recipe_names: Vec<PackageName> = Vec::new();
|
|
let mut override_filesystem_repo_binary = false;
|
|
for arg in args {
|
|
if arg.starts_with("--") {
|
|
if let Some((key, value)) = arg.split_once('=') {
|
|
match key {
|
|
"--cookbook" => config.cookbook_dir = PathBuf::from(value),
|
|
"--repo" => config.repo_dir = PathBuf::from(value),
|
|
"--sysroot" => config.sysroot_dir = PathBuf::from(value),
|
|
"--category" => config.category = Some(PathBuf::from(value)),
|
|
"--filesystem" => {
|
|
config.filesystem = Some({
|
|
let r = redox_installer::Config::from_file(&PathBuf::from(value));
|
|
r.context("Unable to read filesystem installer config")?
|
|
})
|
|
}
|
|
_ => {
|
|
eprintln!("Error: Unknown flag with value: {}", arg);
|
|
process::exit(1);
|
|
}
|
|
}
|
|
} else if arg.starts_with("--category-") {
|
|
// to workaround make command limit we provide this option
|
|
config.category = Some(PathBuf::from(arg[("--category-").len()..].to_owned()));
|
|
} else {
|
|
match arg.as_str() {
|
|
"--repo-binary" => override_filesystem_repo_binary = true,
|
|
"--with-package-deps" => config.with_package_deps = true,
|
|
"--all" => config.all = true,
|
|
_ => {
|
|
eprintln!("Error: Unknown flag: {}", arg);
|
|
process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
} else if arg.starts_with('-') {
|
|
match arg.as_str() {
|
|
_ => {
|
|
eprintln!("Error: Unknown flag: {}", arg);
|
|
process::exit(1);
|
|
}
|
|
}
|
|
} else if command.is_none() {
|
|
// The first non-flag argument is the command
|
|
command = Some(arg);
|
|
} else {
|
|
// Subsequent non-flag arguments are recipe names
|
|
recipe_names.push(arg.try_into().context("Invalid package name")?);
|
|
}
|
|
}
|
|
|
|
if let Some(c) = config.category {
|
|
// need to prefix by cookbook dir
|
|
config.category = Some(PathBuf::from("recipes").join(c));
|
|
}
|
|
if let Some(c) = config.logs_dir.as_mut() {
|
|
fs::create_dir_all(c).map_err(|e| anyhow!(e))?;
|
|
}
|
|
if override_filesystem_repo_binary {
|
|
if let Some(conf) = config.filesystem.as_mut() {
|
|
conf.general.repo_binary = Some(true);
|
|
}
|
|
}
|
|
|
|
let command = command.ok_or(anyhow!("Error: No command specified."))?;
|
|
let command: CliCommand = str::parse(&command)?;
|
|
let mut recipes = if config.all || config.category.is_some() {
|
|
if !recipe_names.is_empty() {
|
|
bail!("Do not specify recipe names when using the --all or --category flag.");
|
|
}
|
|
if config.all && config.category.is_some() {
|
|
bail!("Do not specify both --all and --category flag.");
|
|
}
|
|
if config.all && !command.is_cleaning() {
|
|
// because read_recipe is false by logic below
|
|
// some recipes on wip folders are invalid anyway
|
|
bail!(
|
|
"Refusing to run an unrealistic command to {} all recipes",
|
|
command.to_string()
|
|
);
|
|
}
|
|
match &config.category {
|
|
None => pkg::recipes::list(""),
|
|
Some(prefix) => pkg::recipes::list("")
|
|
.into_iter()
|
|
.filter(|p| p.starts_with(prefix))
|
|
.collect(),
|
|
}
|
|
.iter()
|
|
.map(|f| CookRecipe::from_path(f, !command.is_cleaning()))
|
|
.collect::<Result<Vec<CookRecipe>, PackageError>>()?
|
|
} else {
|
|
if recipe_names.is_empty() {
|
|
if let Some(conf) = config.filesystem.as_ref() {
|
|
recipe_names = conf
|
|
.packages
|
|
.iter()
|
|
.filter_map(|(f, v)| {
|
|
match v {
|
|
PackageConfig::Build(rule) if rule == "ignore" => {
|
|
return None;
|
|
}
|
|
_ => {}
|
|
}
|
|
PackageName::new(f).ok()
|
|
})
|
|
.collect();
|
|
} else {
|
|
bail!(
|
|
"Error: No recipe names or filesystem config provided and --all flag was not used."
|
|
);
|
|
}
|
|
}
|
|
if command.is_building() || (command.is_pushing() && config.with_package_deps) {
|
|
if config.with_package_deps {
|
|
recipe_names = CookRecipe::get_package_deps_recursive(&recipe_names, true)
|
|
.context("failed get package deps")?;
|
|
}
|
|
CookRecipe::get_build_deps_recursive(
|
|
&recipe_names,
|
|
!command.is_pushing(),
|
|
// In CliCommand::Cook, is_deps==true will make it skip checking source
|
|
command.is_pushing() || !config.with_package_deps,
|
|
)?
|
|
} else {
|
|
recipe_names
|
|
.iter()
|
|
.map(|f| CookRecipe::from_name(f.clone()).unwrap())
|
|
.collect()
|
|
}
|
|
};
|
|
if let Some(conf) = config.filesystem.as_ref()
|
|
&& !command.is_cleaning()
|
|
{
|
|
for recipe in recipes.iter_mut() {
|
|
if let Some(recipe_conf) = conf.packages.get(recipe.name.as_str()) {
|
|
match recipe_conf {
|
|
// build from source as usual
|
|
PackageConfig::Build(rule) if rule == "source" => {}
|
|
// keep local changes
|
|
PackageConfig::Build(rule) if rule == "local" => recipe.recipe.source = None,
|
|
// download from remote build
|
|
PackageConfig::Build(rule) if rule == "binary" => {
|
|
recipe.recipe.source = None;
|
|
recipe.recipe.build.set_as_remote();
|
|
}
|
|
// don't build this recipe (unlikely to go here unless some deps need it)
|
|
// TODO: Note that we're assuming this being ignored from e.g. metapackages
|
|
// TODO: Will totally broke build if this recipe needed as some other build dependencies
|
|
PackageConfig::Build(rule) if rule == "ignore" => {
|
|
recipe.recipe.source = None;
|
|
recipe.recipe.build.set_as_none();
|
|
}
|
|
PackageConfig::Build(rule) => {
|
|
bail!(
|
|
// Fail fast because we could risk losing local changes if "local" was typo'ed
|
|
"Invalid pkg config {} = \"{}\"\nExpecting either 'source', 'local', 'binary' or 'ignore'",
|
|
recipe.name.as_str(),
|
|
rule
|
|
);
|
|
}
|
|
_ => {
|
|
if conf.general.repo_binary == Some(true) {
|
|
// same reason as Build("binary")
|
|
recipe.recipe.source = None;
|
|
recipe.recipe.build.set_as_remote();
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if conf.general.repo_binary == Some(true) {
|
|
recipe.recipe.source = None;
|
|
recipe.recipe.build.set_as_remote();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if command.is_informational() {
|
|
// avoid extra data that clobber stdout
|
|
config.cook.verbose = false;
|
|
}
|
|
|
|
Ok((config, command, recipes))
|
|
}
|
|
|
|
fn handle_fetch(
|
|
recipe: &CookRecipe,
|
|
config: &CliConfig,
|
|
allow_offline: bool,
|
|
logger: &PtyOut,
|
|
) -> anyhow::Result<PathBuf> {
|
|
let recipe_dir = &recipe.dir;
|
|
let source_dir = match config.cook.offline && allow_offline {
|
|
true => fetch_offline(recipe_dir, &recipe.recipe, logger),
|
|
false => fetch(recipe_dir, &recipe.recipe, logger),
|
|
}
|
|
.map_err(|e| anyhow!("failed to fetch: {:?}", e))?;
|
|
|
|
Ok(source_dir)
|
|
}
|
|
|
|
fn handle_cook(
|
|
recipe: &CookRecipe,
|
|
config: &CliConfig,
|
|
source_dir: PathBuf,
|
|
is_deps: bool,
|
|
logger: &PtyOut,
|
|
) -> anyhow::Result<()> {
|
|
let recipe_dir = &recipe.dir;
|
|
let target_dir = create_target_dir(recipe_dir, recipe.target).map_err(|e| anyhow!(e))?;
|
|
let (stage_dir, auto_deps) = build(
|
|
recipe_dir,
|
|
&source_dir,
|
|
&target_dir,
|
|
&recipe.name,
|
|
&recipe.recipe,
|
|
config.cook.offline,
|
|
!is_deps,
|
|
logger,
|
|
)
|
|
.map_err(|err| anyhow!("failed to build: {:?}", err))?;
|
|
|
|
package(
|
|
&stage_dir,
|
|
&target_dir,
|
|
&recipe.name,
|
|
&recipe.recipe,
|
|
&auto_deps,
|
|
logger,
|
|
)
|
|
.map_err(|err| anyhow!("failed to package: {:?}", err))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_clean(
|
|
recipe: &CookRecipe,
|
|
_config: &CliConfig,
|
|
source: bool,
|
|
target: bool,
|
|
) -> anyhow::Result<()> {
|
|
let dir = recipe.dir.join("target");
|
|
if dir.exists() && target {
|
|
fs::remove_dir_all(&dir).context(format!("failed to delete {}", dir.display()))?;
|
|
}
|
|
let dir = recipe.dir.join("source");
|
|
if dir.exists() && source {
|
|
fs::remove_dir_all(&dir).context(format!("failed to delete {}", dir.display()))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
static PUSH_SYSROOT_DIR: OnceLock<PathBuf> = OnceLock::new();
|
|
fn handle_push(recipes: &Vec<CookRecipe>, config: &CliConfig) -> anyhow::Result<()> {
|
|
let recipe_map: HashMap<&PackageName, &CookRecipe> =
|
|
recipes.iter().map(|r| (&r.name, r)).collect();
|
|
let mut total_size: u64 = 0;
|
|
let mut visited: HashSet<PackageName> = HashSet::new();
|
|
let roots: Vec<&CookRecipe> = recipes.iter().filter(|r| !r.is_deps).collect();
|
|
let num_roots = roots.len();
|
|
PUSH_SYSROOT_DIR.set(config.sysroot_dir.clone()).unwrap();
|
|
let handle_push_inner = move |package_name: &PackageName,
|
|
_prefix: &str,
|
|
_is_last: bool,
|
|
entry: &WalkTreeEntry|
|
|
-> anyhow::Result<()> {
|
|
let public_path = "build/id_ed25519.pub.toml";
|
|
let r = match entry {
|
|
WalkTreeEntry::Built(archive_path, _) => {
|
|
let sysroot_dir = PUSH_SYSROOT_DIR.get().unwrap();
|
|
pkgar::extract(public_path, archive_path.as_path(), sysroot_dir).context(format!(
|
|
"failed to install '{}' in '{}'",
|
|
archive_path.display(),
|
|
sysroot_dir.display(),
|
|
))
|
|
}
|
|
WalkTreeEntry::NotBuilt => Err(anyhow!(
|
|
"Package {} has not been built",
|
|
package_name.name()
|
|
)),
|
|
WalkTreeEntry::Deduped | WalkTreeEntry::Missing => {
|
|
return Ok(());
|
|
}
|
|
};
|
|
match r {
|
|
Ok(()) => {
|
|
print_success(&CliCommand::Push, Some(package_name));
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
print_failed(&CliCommand::Push, package_name);
|
|
if get_config().cook.nonstop {
|
|
Ok(())
|
|
} else {
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
};
|
|
if config.with_package_deps {
|
|
for (i, root) in roots.iter().enumerate() {
|
|
walk_tree_entry(
|
|
&root.name,
|
|
&recipe_map,
|
|
"",
|
|
i == num_roots - 1,
|
|
&mut visited,
|
|
&mut total_size,
|
|
handle_push_inner,
|
|
)?;
|
|
}
|
|
} else {
|
|
for (i, root) in roots.iter().enumerate() {
|
|
let archive_path = config
|
|
.repo_dir
|
|
.join(redoxer::target())
|
|
.join(format!("{}.pkgar", root.name));
|
|
let metadata = std::fs::metadata(&archive_path);
|
|
handle_push_inner(
|
|
&root.name,
|
|
"",
|
|
i == num_roots - 1,
|
|
&match metadata {
|
|
Ok(m) => {
|
|
total_size += m.len();
|
|
visited.insert(root.name.clone());
|
|
WalkTreeEntry::Built(&archive_path, m.len())
|
|
}
|
|
Err(_) => WalkTreeEntry::NotBuilt,
|
|
},
|
|
)?;
|
|
}
|
|
}
|
|
|
|
if config.cook.verbose {
|
|
println!("");
|
|
println!(
|
|
"Pushed {} of {} {}",
|
|
format_size(total_size),
|
|
visited.len(),
|
|
if visited.len() == 1 {
|
|
"package"
|
|
} else {
|
|
"packages"
|
|
},
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_tree(recipes: &Vec<CookRecipe>, _config: &CliConfig) -> anyhow::Result<()> {
|
|
let recipe_map: HashMap<&PackageName, &CookRecipe> =
|
|
recipes.iter().map(|r| (&r.name, r)).collect();
|
|
let mut total_size: u64 = 0;
|
|
let mut visited: HashSet<PackageName> = HashSet::new();
|
|
let roots: Vec<&CookRecipe> = recipes.iter().filter(|r| !r.is_deps).collect();
|
|
let num_roots = roots.len();
|
|
for (i, root) in roots.iter().enumerate() {
|
|
display_tree_entry(
|
|
&root.name,
|
|
&recipe_map,
|
|
"",
|
|
i == num_roots - 1,
|
|
&mut visited,
|
|
&mut total_size,
|
|
)?;
|
|
}
|
|
|
|
println!("");
|
|
println!(
|
|
"Estimated image size: {} of {} {}",
|
|
format_size(total_size),
|
|
visited.len(),
|
|
if visited.len() == 1 {
|
|
"package"
|
|
} else {
|
|
"packages"
|
|
},
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
//
|
|
// ------------- TUI SPECIFIC CODE -------------------
|
|
//
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
enum RecipeStatus {
|
|
Pending,
|
|
Fetching,
|
|
Fetched,
|
|
Cooking,
|
|
Done,
|
|
Failed(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
enum StatusUpdate {
|
|
StartFetch(PackageName),
|
|
Fetched(CookRecipe),
|
|
FailFetch(CookRecipe, String),
|
|
StartCook(PackageName),
|
|
Cooked(CookRecipe),
|
|
FailCook(CookRecipe, String),
|
|
PushLog(PackageName, Vec<u8>),
|
|
FlushLog(PackageName, PathBuf),
|
|
FetchThreadFinished,
|
|
CookThreadFinished,
|
|
}
|
|
|
|
#[derive(PartialEq)]
|
|
enum JobType {
|
|
Fetch,
|
|
Cook,
|
|
}
|
|
|
|
impl ToString for JobType {
|
|
fn to_string(&self) -> String {
|
|
match self {
|
|
JobType::Fetch => "Fetch",
|
|
JobType::Cook => "Cook",
|
|
}
|
|
.to_string()
|
|
}
|
|
}
|
|
|
|
struct TuiApp {
|
|
recipes: Vec<(CookRecipe, RecipeStatus)>,
|
|
fetch_queue: VecDeque<CookRecipe>,
|
|
cook_queue: VecDeque<CookRecipe>,
|
|
done: Vec<PackageName>,
|
|
active_fetch: Option<PackageName>,
|
|
active_cook: Option<PackageName>,
|
|
logs: HashMap<PackageName, Vec<String>>,
|
|
log_byte_buffer: HashMap<PackageName, Vec<u8>>,
|
|
log_scroll: usize,
|
|
log_view_job: JobType,
|
|
auto_scroll: bool,
|
|
fetch_scroll: usize,
|
|
cook_scroll: usize,
|
|
cook_auto_scroll: bool,
|
|
cook_list_state: ListState,
|
|
fetch_complete: bool,
|
|
cook_complete: bool,
|
|
fetch_panel_rect: Option<Rect>,
|
|
cook_panel_rect: Option<Rect>,
|
|
log_panel_rect: Option<Rect>,
|
|
prompt: Option<FailurePrompt>,
|
|
dump_logs_anyway: bool,
|
|
dump_logs_on_exit: Option<(PackageName, String)>,
|
|
}
|
|
|
|
impl TuiApp {
|
|
fn new(recipes: Vec<CookRecipe>) -> Self {
|
|
Self {
|
|
recipes: recipes
|
|
.iter()
|
|
.cloned()
|
|
.map(|r| (r, RecipeStatus::Pending))
|
|
.collect(),
|
|
fetch_queue: recipes.iter().cloned().map(|r| r.clone()).collect(),
|
|
cook_queue: VecDeque::new(),
|
|
done: Vec::new(),
|
|
active_fetch: None,
|
|
active_cook: None,
|
|
logs: HashMap::new(),
|
|
log_byte_buffer: HashMap::new(),
|
|
log_scroll: 0,
|
|
auto_scroll: true,
|
|
log_view_job: JobType::Fetch,
|
|
fetch_scroll: 0,
|
|
cook_scroll: 0,
|
|
cook_auto_scroll: true,
|
|
cook_list_state: ListState::default(),
|
|
fetch_complete: false,
|
|
cook_complete: false,
|
|
fetch_panel_rect: None,
|
|
cook_panel_rect: None,
|
|
log_panel_rect: None,
|
|
prompt: None,
|
|
dump_logs_anyway: false,
|
|
dump_logs_on_exit: None,
|
|
}
|
|
}
|
|
|
|
pub fn get_active_name(&self) -> Option<PackageName> {
|
|
if self.log_view_job == JobType::Cook {
|
|
self.active_cook.clone()
|
|
} else {
|
|
self.active_fetch.clone()
|
|
}
|
|
}
|
|
|
|
pub fn get_active_log(
|
|
&self,
|
|
) -> (
|
|
Option<PackageName>,
|
|
Option<&Vec<String>>,
|
|
Option<Cow<'_, str>>,
|
|
) {
|
|
let active_name = self.get_active_name();
|
|
let (log_text, log_line) = if let Some(active_name) = active_name.as_ref() {
|
|
self.get_recipe_log(active_name)
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
(active_name, log_text, log_line)
|
|
}
|
|
|
|
pub fn get_recipe_log(
|
|
&self,
|
|
recipe_name: &PackageName,
|
|
) -> (Option<&Vec<String>>, Option<Cow<'_, str>>) {
|
|
let log_text = self.logs.get(recipe_name);
|
|
let log_line = if let Some(b) = self.log_byte_buffer.get(recipe_name) {
|
|
Some(String::from_utf8_lossy(b))
|
|
} else {
|
|
None
|
|
};
|
|
(log_text, log_line)
|
|
}
|
|
|
|
pub fn write_log(&self, recipe_name: &PackageName, log_path: &PathBuf) -> anyhow::Result<()> {
|
|
let (Some(logs), line) = self.get_recipe_log(recipe_name) else {
|
|
return Ok(());
|
|
};
|
|
let str = strip_ansi_escapes::strip_str(join_logs(logs, line));
|
|
if !str.trim_end().is_empty() {
|
|
fs::write(log_path, str)?;
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// Update the state based on a message from a worker thread
|
|
fn update_status(&mut self, update: StatusUpdate) {
|
|
let (name, new_status) = match update {
|
|
StatusUpdate::StartFetch(name) => {
|
|
self.active_fetch = Some(name.clone());
|
|
self.logs.insert(name.clone(), Vec::new());
|
|
self.log_byte_buffer.insert(name.clone(), Vec::new());
|
|
self.log_scroll = 0;
|
|
self.auto_scroll = true;
|
|
(name.clone(), RecipeStatus::Fetching)
|
|
}
|
|
StatusUpdate::Fetched(recipe) => (recipe.name.clone(), RecipeStatus::Fetched),
|
|
StatusUpdate::FailFetch(recipe, err) => {
|
|
self.prompt = Some(FailurePrompt::new(recipe.clone(), err.clone()));
|
|
(recipe.name.clone(), RecipeStatus::Failed(err))
|
|
}
|
|
StatusUpdate::StartCook(name) => {
|
|
self.active_cook = Some(name.clone());
|
|
self.logs.insert(name.clone(), Vec::new());
|
|
self.log_byte_buffer.insert(name.clone(), Vec::new());
|
|
(name.clone(), RecipeStatus::Cooking)
|
|
}
|
|
StatusUpdate::PushLog(name, chunk) => {
|
|
let buffer = self.log_byte_buffer.entry(name.clone()).or_default();
|
|
buffer.extend_from_slice(&chunk);
|
|
if self.dump_logs_anyway {
|
|
let _ = std::io::stdout().write_all(&chunk);
|
|
}
|
|
let log_list = self.logs.entry(name.clone()).or_default();
|
|
while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
|
|
let line_bytes = buffer.drain(..=newline_pos).collect::<Vec<u8>>();
|
|
let line_str = String::from_utf8_lossy(&line_bytes).into_owned();
|
|
let line_str_pos = line_str.trim_end();
|
|
let line_str = line_str_pos.rsplit('\r').next().unwrap_or(&line_str_pos);
|
|
log_list.push(line_str.to_owned());
|
|
}
|
|
return;
|
|
}
|
|
StatusUpdate::FlushLog(name, path) => {
|
|
// TODO: This blocks the TUI, maybe open separate thread?
|
|
// FIXME: handle error here?
|
|
let _ = self.write_log(&name, &path);
|
|
return;
|
|
}
|
|
StatusUpdate::Cooked(recipe) => {
|
|
if self.active_cook.as_ref() == Some(&recipe.name) {
|
|
self.active_cook = None;
|
|
}
|
|
self.auto_scroll = true;
|
|
(recipe.name.clone(), RecipeStatus::Done)
|
|
}
|
|
StatusUpdate::FailCook(recipe, err) => {
|
|
self.prompt = Some(FailurePrompt::new(recipe.clone(), err.clone()));
|
|
(recipe.name.clone(), RecipeStatus::Failed(err))
|
|
}
|
|
StatusUpdate::FetchThreadFinished => {
|
|
self.fetch_complete = true;
|
|
self.log_view_job = JobType::Cook;
|
|
return;
|
|
}
|
|
StatusUpdate::CookThreadFinished => {
|
|
self.cook_complete = true;
|
|
return;
|
|
}
|
|
};
|
|
|
|
if let Some((_, status)) = self.recipes.iter_mut().find(|(r, _)| r.name == name) {
|
|
*status = new_status;
|
|
}
|
|
|
|
// Re-compute the queues for display
|
|
self.fetch_queue = self
|
|
.recipes
|
|
.iter()
|
|
.filter(|(_, s)| *s == RecipeStatus::Pending)
|
|
.map(|(r, _)| r.clone())
|
|
.collect();
|
|
self.cook_queue = self
|
|
.recipes
|
|
.iter()
|
|
.filter(|(_, s)| *s == RecipeStatus::Fetched)
|
|
.map(|(r, _)| r.clone())
|
|
.collect();
|
|
self.done = self
|
|
.recipes
|
|
.iter()
|
|
.filter(|(_, s)| *s == RecipeStatus::Done)
|
|
.map(|(r, _)| r.name.clone())
|
|
.collect();
|
|
}
|
|
}
|
|
|
|
fn run_tui_cook(
|
|
config: CliConfig,
|
|
recipes: Vec<CookRecipe>,
|
|
) -> anyhow::Result<Option<(PackageName, String)>> {
|
|
let (work_tx, work_rx) = mpsc::channel::<(CookRecipe, PathBuf)>();
|
|
let (status_tx, status_rx) = mpsc::channel::<StatusUpdate>();
|
|
|
|
let running = Arc::new(AtomicBool::new(true));
|
|
let prompting = Arc::new(AtomicU32::new(0));
|
|
const TICK_RATE: Duration = Duration::from_millis(100);
|
|
|
|
// ---- Cooker Thread ----
|
|
let cooker_config = config.clone();
|
|
let cooker_status_tx = status_tx.clone();
|
|
let cooker_prompting = prompting.clone();
|
|
let cooker_handle = thread::spawn(move || {
|
|
'done: for (recipe, source_dir) in work_rx {
|
|
let name = recipe.name.clone();
|
|
let is_deps = recipe.is_deps;
|
|
let (mut stdout_writer, mut stderr_writer) = setup_logger(&cooker_status_tx, &name);
|
|
let mut logger = Some((&mut stdout_writer, &mut stderr_writer));
|
|
'again: loop {
|
|
cooker_status_tx
|
|
.send(StatusUpdate::StartCook(name.clone()))
|
|
.unwrap();
|
|
let handler = handle_cook(
|
|
&recipe,
|
|
&cooker_config,
|
|
source_dir.clone(),
|
|
is_deps,
|
|
&logger,
|
|
);
|
|
if let Some(log_path) = cooker_config.logs_dir.as_ref() {
|
|
if let Err(err_ctx) = &handler {
|
|
log_to_pty!(&logger, "\n{:?}", err_ctx)
|
|
}
|
|
flush_pty(&mut logger);
|
|
let log_path =
|
|
log_path.join(format!("{}/{}.log", package_target(&name), name.name()));
|
|
cooker_status_tx
|
|
.send(StatusUpdate::FlushLog(name.clone(), log_path))
|
|
.unwrap_or_default();
|
|
}
|
|
match handler {
|
|
Ok(()) => {
|
|
cooker_status_tx
|
|
.send(StatusUpdate::Cooked(recipe))
|
|
.unwrap_or_default();
|
|
if cooker_config.cook.nonstop
|
|
&& cooker_prompting.load(Ordering::SeqCst) == 4
|
|
{
|
|
break 'done;
|
|
}
|
|
break;
|
|
}
|
|
Err(e) => {
|
|
cooker_status_tx
|
|
.send(StatusUpdate::FailCook(recipe.clone(), e.to_string()))
|
|
.unwrap_or_default();
|
|
if cooker_config.cook.nonstop {
|
|
if cooker_prompting.load(Ordering::SeqCst) == 4 {
|
|
break 'done;
|
|
}
|
|
break;
|
|
}
|
|
while cooker_prompting.load(Ordering::SeqCst) != 0 {
|
|
thread::sleep(Duration::from_millis(101)); // wait other prompt
|
|
}
|
|
cooker_prompting.swap(1, Ordering::SeqCst);
|
|
'wait: loop {
|
|
match cooker_prompting.load(Ordering::SeqCst) {
|
|
0 => break 'again,
|
|
1 => thread::sleep(Duration::from_millis(101)),
|
|
2 => {
|
|
cooker_prompting.swap(0, Ordering::SeqCst);
|
|
break 'wait;
|
|
} // retry
|
|
3 => {
|
|
cooker_prompting.swap(0, Ordering::SeqCst);
|
|
break 'again;
|
|
} // skip
|
|
4 => {
|
|
cooker_prompting.swap(0, Ordering::SeqCst);
|
|
break 'done;
|
|
} // done
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
cooker_status_tx
|
|
.send(StatusUpdate::CookThreadFinished)
|
|
.unwrap_or_default();
|
|
});
|
|
|
|
let mstdin = stdin();
|
|
let mstdout = stdout()
|
|
.into_raw_mode()
|
|
.unwrap()
|
|
.into_alternate_screen()
|
|
.unwrap();
|
|
|
|
// ----- Input Thread -----
|
|
let (input_tx, input_rx) = mpsc::channel::<Event>();
|
|
let _input_handle = thread::spawn(move || {
|
|
for evt in mstdin.events() {
|
|
if let Ok(evt) = evt {
|
|
if input_tx.send(evt).is_err() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// ---- Fetcher Thread ----
|
|
let fetcher_recipes = recipes.clone();
|
|
let fetcher_status_tx = status_tx.clone();
|
|
let fetcher_config = config.clone();
|
|
let fetcher_prompting = prompting.clone();
|
|
let fetcher_handle = thread::spawn(move || {
|
|
'done: for recipe in fetcher_recipes {
|
|
let name = recipe.name.clone();
|
|
let (mut stdout_writer, mut stderr_writer) = setup_logger(&fetcher_status_tx, &name);
|
|
let mut logger = Some((&mut stdout_writer, &mut stderr_writer));
|
|
'again: loop {
|
|
fetcher_status_tx
|
|
.send(StatusUpdate::StartFetch(name.clone()))
|
|
.unwrap();
|
|
let handler = handle_fetch(&recipe, &fetcher_config, true, &logger);
|
|
if let Some(log_path) = fetcher_config.logs_dir.as_ref()
|
|
// successful fetch log usually not that helpful
|
|
&& handler.is_err()
|
|
{
|
|
if let Err(err_ctx) = &handler {
|
|
log_to_pty!(&logger, "\n{:?}", err_ctx)
|
|
}
|
|
flush_pty(&mut logger);
|
|
let log_path = log_path.join(format!("{}/{}.log", recipe.target, name.name()));
|
|
fetcher_status_tx
|
|
.send(StatusUpdate::FlushLog(name.clone(), log_path))
|
|
.unwrap_or_default();
|
|
}
|
|
match handler {
|
|
Ok(source_dir) => {
|
|
fetcher_status_tx
|
|
.send(StatusUpdate::Fetched(recipe.clone()))
|
|
.unwrap();
|
|
if work_tx.send((recipe.clone(), source_dir)).is_err() {
|
|
// Cooker thread died
|
|
break 'done;
|
|
}
|
|
if fetcher_config.cook.nonstop
|
|
&& fetcher_prompting.load(Ordering::SeqCst) == 4
|
|
{
|
|
break 'done;
|
|
}
|
|
break;
|
|
}
|
|
Err(e) => {
|
|
fetcher_status_tx
|
|
.send(StatusUpdate::FailFetch(recipe.clone(), e.to_string()))
|
|
.unwrap_or_default();
|
|
if fetcher_config.cook.nonstop {
|
|
if fetcher_prompting.load(Ordering::SeqCst) == 4 {
|
|
break 'done;
|
|
}
|
|
break;
|
|
}
|
|
while fetcher_prompting.load(Ordering::SeqCst) != 0 {
|
|
thread::sleep(Duration::from_millis(101)); // wait other prompt
|
|
}
|
|
fetcher_prompting.swap(1, Ordering::SeqCst);
|
|
'wait: loop {
|
|
match fetcher_prompting.load(Ordering::SeqCst) {
|
|
0 => break 'again,
|
|
1 => thread::sleep(Duration::from_millis(101)),
|
|
2 => {
|
|
fetcher_prompting.swap(0, Ordering::SeqCst);
|
|
break 'wait;
|
|
} // retry
|
|
3 => {
|
|
fetcher_prompting.swap(0, Ordering::SeqCst);
|
|
break 'again;
|
|
} // skip
|
|
4 => {
|
|
fetcher_prompting.swap(0, Ordering::SeqCst);
|
|
break 'done;
|
|
} // done
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
status_tx
|
|
.send(StatusUpdate::FetchThreadFinished)
|
|
.unwrap_or_default();
|
|
});
|
|
|
|
let mut terminal = Terminal::new(TermionBackend::new(stdout()))?;
|
|
terminal.clear()?;
|
|
|
|
let mut app = TuiApp::new(recipes);
|
|
|
|
let spinner = ['-', '\\', '|', '/'];
|
|
let mut spinner_i = 0;
|
|
|
|
while running.load(Ordering::SeqCst) {
|
|
let frame_start = Instant::now();
|
|
terminal.draw(|f| {
|
|
spinner_i = (spinner_i + 1) % spinner.len();
|
|
let spin = spinner[spinner_i];
|
|
|
|
let mut constraints = Vec::new();
|
|
if !app.fetch_complete {
|
|
constraints.push(Constraint::Length(22));
|
|
}
|
|
constraints.push(Constraint::Length(22));
|
|
constraints.push(Constraint::Min(20));
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints(constraints)
|
|
.split(f.area());
|
|
let panel_height = chunks[0].height.saturating_sub(2) as usize;
|
|
|
|
// Left Pane
|
|
let fetch_items: Vec<ListItem> = app
|
|
.recipes
|
|
.iter()
|
|
.filter(|(_, s)| *s == RecipeStatus::Pending || *s == RecipeStatus::Fetching)
|
|
.map(|(r, s)| {
|
|
let style = if *s == RecipeStatus::Fetching {
|
|
Style::default().fg(Color::Yellow)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
let icon = match s {
|
|
RecipeStatus::Pending => ' ',
|
|
RecipeStatus::Fetching => spin,
|
|
_ => '?',
|
|
};
|
|
|
|
ListItem::new(format!("{icon} {}", r.name)).style(style)
|
|
})
|
|
.collect();
|
|
let fetch_list = List::new(fetch_items).block(
|
|
Block::default()
|
|
.title("Fetch Queue [1]")
|
|
.borders(Borders::ALL),
|
|
);
|
|
f.render_widget(fetch_list, chunks[0]);
|
|
|
|
// Right Pane
|
|
let cook_items: Vec<ListItem> = app
|
|
.recipes
|
|
.iter()
|
|
.filter(|(_, s)| {
|
|
*s == RecipeStatus::Fetched
|
|
|| *s == RecipeStatus::Cooking
|
|
|| *s == RecipeStatus::Done
|
|
|| matches!(s, RecipeStatus::Failed(_))
|
|
})
|
|
.map(|(r, s)| {
|
|
let style = match s {
|
|
RecipeStatus::Fetched => Style::default().fg(Color::Cyan),
|
|
RecipeStatus::Cooking => Style::default().fg(Color::Yellow),
|
|
RecipeStatus::Done => Style::default().fg(Color::Green),
|
|
RecipeStatus::Failed(_) => Style::default().fg(Color::Red),
|
|
_ => Style::default(),
|
|
};
|
|
let icon = match s {
|
|
RecipeStatus::Fetched => ' ',
|
|
RecipeStatus::Cooking => spin,
|
|
RecipeStatus::Done => ' ',
|
|
RecipeStatus::Failed(_) => 'X',
|
|
_ => '?',
|
|
};
|
|
ListItem::new(format!("{icon} {}", r.name)).style(style)
|
|
})
|
|
.collect();
|
|
let total_items = cook_items.len();
|
|
if app.cook_auto_scroll {
|
|
let cooking_index = app
|
|
.recipes
|
|
.iter()
|
|
.filter(|(_, s)| {
|
|
*s == RecipeStatus::Fetched
|
|
|| *s == RecipeStatus::Cooking
|
|
|| *s == RecipeStatus::Done
|
|
|| matches!(s, RecipeStatus::Failed(_))
|
|
})
|
|
.position(|(_r, s)| *s == RecipeStatus::Cooking);
|
|
|
|
if let Some(index) = cooking_index {
|
|
app.cook_list_state.select(Some(index));
|
|
let index_u16 = index;
|
|
let center_offset = panel_height / 2;
|
|
let new_offset = index_u16.saturating_sub(center_offset) as usize;
|
|
|
|
*app.cook_list_state.offset_mut() = new_offset;
|
|
}
|
|
} else {
|
|
app.cook_list_state.select(None);
|
|
if total_items > 0 {
|
|
let max_offset = total_items.saturating_sub(panel_height as usize);
|
|
if *app.cook_list_state.offset_mut() > max_offset {
|
|
*app.cook_list_state.offset_mut() = max_offset;
|
|
}
|
|
} else {
|
|
*app.cook_list_state.offset_mut() = 0;
|
|
}
|
|
}
|
|
let cook_items: Vec<ListItem> = cook_items[app.cook_scroll..].into();
|
|
let cook_chunk = chunks[if app.fetch_complete { 0 } else { 1 }];
|
|
let cook_list = List::new(cook_items).block(
|
|
Block::default()
|
|
.title("Cook Queue [2]")
|
|
.borders(Borders::ALL),
|
|
);
|
|
f.render_stateful_widget(cook_list, cook_chunk, &mut app.cook_list_state);
|
|
|
|
let (active_name, log_text, log_line) = app.get_active_log();
|
|
let log_title = if let Some(active_name) = active_name {
|
|
format!(
|
|
" {} Log: {} ",
|
|
app.log_view_job.to_string(),
|
|
active_name.as_str()
|
|
)
|
|
} else {
|
|
format!(" {} Log ", app.log_view_job.to_string())
|
|
};
|
|
|
|
let mut enable_auto_scroll = false;
|
|
let mut intended_scroll_pos = 0usize;
|
|
|
|
let mut log_lines: Vec<Line> = if let Some(log_text) = log_text
|
|
&& !log_text.is_empty()
|
|
{
|
|
let total_log_lines = log_text.len() as usize;
|
|
|
|
let start = if app.auto_scroll {
|
|
if total_log_lines > panel_height {
|
|
intended_scroll_pos = total_log_lines - panel_height;
|
|
total_log_lines - panel_height
|
|
} else {
|
|
0
|
|
}
|
|
} else {
|
|
if total_log_lines > panel_height {
|
|
let limit = 2; // arbitrary number
|
|
if app.log_scroll >= total_log_lines - limit {
|
|
if app.prompt.is_none() || config.cook.nonstop {
|
|
enable_auto_scroll = true;
|
|
}
|
|
intended_scroll_pos = total_log_lines - limit;
|
|
total_log_lines - limit
|
|
} else {
|
|
app.log_scroll
|
|
}
|
|
} else {
|
|
0
|
|
}
|
|
};
|
|
|
|
let end = cmp::min(panel_height + start, total_log_lines - 1);
|
|
|
|
log_text[start..end]
|
|
.iter()
|
|
.map(|s| {
|
|
let text_with_colors = s
|
|
.into_text()
|
|
.unwrap_or_else(|_| Text::raw("--unrenderable line--"));
|
|
text_with_colors
|
|
.lines
|
|
.into_iter()
|
|
.next()
|
|
.unwrap_or_else(|| Line::raw("--unrenderable line--"))
|
|
})
|
|
.collect()
|
|
} else {
|
|
vec![Line::from("No logs yet")]
|
|
};
|
|
|
|
if let Some(buffer) = log_line
|
|
&& !buffer.is_empty()
|
|
{
|
|
let text_with_colors = handle_cr(&buffer)
|
|
.into_text()
|
|
.unwrap_or_else(|_| Text::raw("--unrenderable line--"));
|
|
|
|
if let Some(line) = text_with_colors.lines.into_iter().next() {
|
|
log_lines.push(line);
|
|
}
|
|
}
|
|
|
|
let instruct = format!(
|
|
" Keys: [c] Stop [PageUp/Down] Scroll{}{} ",
|
|
match app.auto_scroll {
|
|
true => "",
|
|
false => " [End] Follow log trails",
|
|
},
|
|
match (&app.log_view_job, app.fetch_complete) {
|
|
(JobType::Fetch, _) => " [2] View Cook Log",
|
|
(JobType::Cook, false) => " [1] View Fetch Log",
|
|
(JobType::Cook, true) => "",
|
|
}
|
|
);
|
|
|
|
let mut log_paragraph = Paragraph::new(log_lines).block(
|
|
Block::default()
|
|
.title(log_title)
|
|
.title_bottom(instruct)
|
|
.borders(Borders::ALL),
|
|
);
|
|
|
|
if !app.auto_scroll {
|
|
log_paragraph = log_paragraph.wrap(Wrap { trim: false });
|
|
}
|
|
|
|
f.render_widget(
|
|
log_paragraph,
|
|
chunks[if app.fetch_complete { 1 } else { 2 }],
|
|
);
|
|
if let Some(prompt) = &mut app.prompt {
|
|
if config.cook.nonstop && prompt.selected == PromptOption::Retry {
|
|
prompt.selected = PromptOption::Skip;
|
|
}
|
|
draw_prompt(f, prompt, config.cook.nonstop);
|
|
}
|
|
if enable_auto_scroll {
|
|
app.auto_scroll = true;
|
|
}
|
|
if intended_scroll_pos > 0 {
|
|
app.log_scroll = intended_scroll_pos;
|
|
}
|
|
|
|
while let Ok(event) = input_rx.try_recv() {
|
|
if let Some((app, res)) = handle_prompt_input(&event, &mut app) {
|
|
prompting.swap(res as u32, Ordering::SeqCst);
|
|
if res == PromptOption::Exit {
|
|
// TODO: This can be a different log with what prompted on nonstop mode
|
|
let (name, log, line) = app.get_active_log();
|
|
if let Some(name) = name
|
|
&& let Some(log) = log
|
|
{
|
|
app.dump_logs_on_exit = Some((name.to_owned(), join_logs(log, line)));
|
|
}
|
|
running.store(false, Ordering::SeqCst);
|
|
}
|
|
app.prompt = None;
|
|
} else {
|
|
handle_main_event(&mut app, &event);
|
|
}
|
|
}
|
|
})?;
|
|
|
|
while let Ok(update) = status_rx.try_recv() {
|
|
app.update_status(update);
|
|
}
|
|
|
|
if app.cook_complete {
|
|
running.swap(false, Ordering::SeqCst);
|
|
}
|
|
|
|
if let Some(sleep_duration) = TICK_RATE.checked_sub(frame_start.elapsed()) {
|
|
thread::sleep(sleep_duration);
|
|
}
|
|
}
|
|
|
|
drop(mstdout);
|
|
let _ = stdout().flush();
|
|
|
|
if config.cook.nonstop && app.dump_logs_on_exit.is_some() {
|
|
kill_everything();
|
|
}
|
|
|
|
fetcher_handle.join().unwrap();
|
|
cooker_handle.join().unwrap();
|
|
|
|
Ok(app.dump_logs_on_exit)
|
|
}
|
|
|
|
fn join_logs(log: &Vec<String>, line: Option<Cow<'_, str>>) -> String {
|
|
let mut logs = log.join("\n");
|
|
if let Some(line) = line {
|
|
logs.push_str("\n");
|
|
logs.push_str(handle_cr(&line));
|
|
}
|
|
logs
|
|
}
|
|
|
|
fn handle_cr<'a>(buffer: &'a Cow<'_, str>) -> &'a str {
|
|
let st = buffer.trim_end();
|
|
st.rsplit('\r').next().unwrap_or(&st)
|
|
}
|
|
|
|
fn handle_main_event(app: &mut TuiApp, event: &Event) {
|
|
match event {
|
|
Event::Key(key) => match key {
|
|
Key::Char('1') => {
|
|
app.log_view_job = JobType::Fetch;
|
|
}
|
|
Key::Char('2') => {
|
|
app.log_view_job = JobType::Cook;
|
|
}
|
|
Key::Char('c') => {
|
|
// as compilers still running, we use this way to stop it
|
|
kill_everything();
|
|
}
|
|
Key::Up => {
|
|
app.auto_scroll = false;
|
|
app.log_scroll = app.log_scroll.saturating_sub(1);
|
|
}
|
|
Key::Down => {
|
|
app.auto_scroll = false;
|
|
app.log_scroll = app.log_scroll.saturating_add(1);
|
|
}
|
|
Key::PageUp => {
|
|
app.auto_scroll = false;
|
|
app.log_scroll = app.log_scroll.saturating_sub(20);
|
|
}
|
|
Key::PageDown => {
|
|
app.auto_scroll = false;
|
|
app.log_scroll = app.log_scroll.saturating_add(20);
|
|
}
|
|
Key::End => {
|
|
app.auto_scroll = true;
|
|
}
|
|
Key::Home => {
|
|
app.auto_scroll = false;
|
|
app.log_scroll = 0;
|
|
}
|
|
_ => {}
|
|
},
|
|
|
|
//FIXME: This does nothing, it seems ratatui handles this itself magically
|
|
Event::Mouse(mouse_event) => {
|
|
match mouse_event {
|
|
MouseEvent::Press(termion::event::MouseButton::WheelUp, x, y) => {
|
|
// termion is 1-based, ratatui rects are 0-based
|
|
let pos = Position {
|
|
x: x.saturating_sub(1),
|
|
y: y.saturating_sub(1),
|
|
};
|
|
|
|
if app.fetch_panel_rect.map_or(false, |r| r.contains(pos)) {
|
|
app.fetch_scroll = app.fetch_scroll.saturating_sub(1);
|
|
} else if app.cook_panel_rect.map_or(false, |r| r.contains(pos)) {
|
|
app.cook_scroll = app.cook_scroll.saturating_sub(1);
|
|
app.cook_auto_scroll = false;
|
|
} else if app.log_panel_rect.map_or(false, |r| r.contains(pos)) {
|
|
app.auto_scroll = false;
|
|
app.log_scroll = app.log_scroll.saturating_sub(1);
|
|
}
|
|
}
|
|
MouseEvent::Press(termion::event::MouseButton::WheelDown, x, y) => {
|
|
let pos = Position {
|
|
x: x.saturating_sub(1),
|
|
y: y.saturating_sub(1),
|
|
};
|
|
|
|
if app.fetch_panel_rect.map_or(false, |r| r.contains(pos)) {
|
|
app.fetch_scroll = app.fetch_scroll.saturating_add(1);
|
|
} else if app.cook_panel_rect.map_or(false, |r| r.contains(pos)) {
|
|
app.cook_scroll = app.cook_scroll.saturating_add(1);
|
|
app.cook_auto_scroll = false;
|
|
} else if app.log_panel_rect.map_or(false, |r| r.contains(pos)) {
|
|
app.auto_scroll = false;
|
|
app.log_scroll = app.log_scroll.saturating_add(1);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn kill_everything() {
|
|
let pid = std::process::id();
|
|
Command::new("bash")
|
|
.arg("-c")
|
|
.arg(KILL_ALL_PID.replace("$PID", &pid.to_string()))
|
|
.spawn()
|
|
.expect("unable to spawn kill");
|
|
}
|
|
|
|
fn handle_prompt_input<'a>(
|
|
event: &Event,
|
|
app: &'a mut TuiApp,
|
|
) -> Option<(&'a mut TuiApp, PromptOption)> {
|
|
if let Some(prompt) = &mut app.prompt {
|
|
match event {
|
|
Event::Key(key) => match key {
|
|
Key::Char('q') | Key::Ctrl('c') | Key::Esc => {
|
|
// Treat as "Exit"
|
|
return Some((app, PromptOption::Exit));
|
|
}
|
|
Key::Left | Key::BackTab => prompt.prev(),
|
|
Key::Right | Key::Char('\t') => prompt.next(),
|
|
Key::Char('\n') => {
|
|
let prompt = app.prompt.take().unwrap();
|
|
return Some((app, prompt.selected));
|
|
}
|
|
_ => {}
|
|
},
|
|
_ => {} // Ignore mouse events
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn draw_prompt(f: &mut ratatui::Frame, prompt: &FailurePrompt, is_nonstop: bool) {
|
|
let title = format!(
|
|
" FAILURE in {} {}",
|
|
prompt.recipe.name,
|
|
if is_nonstop { "(skipped) " } else { "" }
|
|
);
|
|
let mut error_text = prompt.error.clone();
|
|
if error_text.len() > 200 {
|
|
error_text = error_text[0..100].to_string()
|
|
+ ".."
|
|
+ &error_text[(error_text.len() - 100)..(error_text.len() - 1)];
|
|
} else if error_text.len() > 100 {
|
|
error_text = error_text[0..100].to_string() + "..";
|
|
}
|
|
|
|
// Style for options
|
|
let retry_style = if prompt.selected == PromptOption::Retry {
|
|
Style::default().bg(Color::White).fg(Color::Black)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
let skip_style = if prompt.selected == PromptOption::Skip {
|
|
Style::default().bg(Color::White).fg(Color::Black)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
let exit_style = if prompt.selected == PromptOption::Exit {
|
|
Style::default().bg(Color::White).fg(Color::Black)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
|
|
let mut buttons = vec![
|
|
Span::styled(" [Skip] ", skip_style),
|
|
Span::raw(" "),
|
|
Span::styled(" [Exit] ", exit_style),
|
|
];
|
|
|
|
if !is_nonstop {
|
|
buttons.push(Span::raw(" "));
|
|
buttons.push(Span::styled(" [Retry] ", retry_style));
|
|
}
|
|
|
|
let text = vec![
|
|
Line::from(error_text).style(Style::default().fg(Color::Yellow)),
|
|
Line::from(""),
|
|
Line::from(buttons),
|
|
];
|
|
|
|
let block = Block::default()
|
|
.title(Span::styled(
|
|
title,
|
|
Style::default().fg(Color::White).bg(Color::Red),
|
|
))
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Color::Red));
|
|
|
|
let paragraph = Paragraph::new(text)
|
|
.block(block)
|
|
.alignment(ratatui::layout::Alignment::Center)
|
|
.wrap(Wrap { trim: true });
|
|
|
|
let area = f.area();
|
|
let popup_area = Rect {
|
|
x: area.width / 4,
|
|
y: area.height / 3,
|
|
width: area.width / 2,
|
|
height: 10,
|
|
};
|
|
|
|
f.render_widget(Clear, popup_area); // Clear the background
|
|
f.render_widget(paragraph, popup_area);
|
|
}
|
|
|
|
fn spawn_log_reader<R>(
|
|
mut reader: R,
|
|
package_name: PackageName,
|
|
status_tx: mpsc::Sender<StatusUpdate>,
|
|
) where
|
|
R: Read + Send + 'static,
|
|
{
|
|
thread::spawn(move || {
|
|
let mut buffer = [0; 1024];
|
|
loop {
|
|
let buf = match reader.read(&mut buffer) {
|
|
Ok(0) => break,
|
|
Ok(n) => buffer[..n].to_vec(),
|
|
Err(e) => format!("[IO Error] {}", e).into_bytes(),
|
|
};
|
|
if status_tx
|
|
.send(StatusUpdate::PushLog(package_name.clone(), buf))
|
|
.is_err()
|
|
{
|
|
// TUI thread hung up
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn setup_logger(
|
|
status_tx: &mpsc::Sender<StatusUpdate>,
|
|
name: &PackageName,
|
|
) -> (UnixSlavePty, std::io::PipeWriter) {
|
|
let (pty_reader, log_reader, pipes) = setup_pty();
|
|
|
|
spawn_log_reader(pty_reader, name.clone(), status_tx.clone());
|
|
spawn_log_reader(log_reader, name.clone(), status_tx.clone());
|
|
pipes
|
|
}
|
|
|
|
#[derive(PartialEq, Clone, Copy)]
|
|
#[repr(u32)]
|
|
enum PromptOption {
|
|
Retry = 2,
|
|
Skip,
|
|
Exit,
|
|
}
|
|
|
|
struct FailurePrompt {
|
|
recipe: CookRecipe,
|
|
error: String,
|
|
selected: PromptOption,
|
|
}
|
|
|
|
impl FailurePrompt {
|
|
fn new(recipe: CookRecipe, error: String) -> Self {
|
|
Self {
|
|
recipe,
|
|
error,
|
|
selected: PromptOption::Exit,
|
|
}
|
|
}
|
|
|
|
fn next(&mut self) {
|
|
self.selected = match self.selected {
|
|
PromptOption::Retry => PromptOption::Skip,
|
|
PromptOption::Skip => PromptOption::Exit,
|
|
PromptOption::Exit => PromptOption::Retry,
|
|
}
|
|
}
|
|
|
|
fn prev(&mut self) {
|
|
self.selected = match self.selected {
|
|
PromptOption::Retry => PromptOption::Exit,
|
|
PromptOption::Skip => PromptOption::Retry,
|
|
PromptOption::Exit => PromptOption::Skip,
|
|
}
|
|
}
|
|
}
|