Merge branch 'repo-editor' into 'master'

Implement new repo_editor

See merge request redox-os/redox!2275
This commit is contained in:
Jeremy Soller 2026-07-26 05:56:30 -06:00
commit fda6d78266
11 changed files with 1437 additions and 170 deletions

View File

@ -23,6 +23,8 @@ cargo-test:
image: "rust:trixie"
stage: lint
script:
# warnings are annoying for user
- export RUSTFLAGS="-Dwarnings"
- cargo test --locked
fetch-changed:

View File

@ -19,6 +19,10 @@ path = "src/bin/repo.rs"
name = "repo_builder"
path = "src/bin/repo_builder.rs"
[[bin]]
name = "repo_editor"
path = "src/bin/repo_editor/main.rs"
[lib]
name = "cookbook"
path = "src/lib.rs"

View File

@ -49,6 +49,13 @@ else
touch $@
endif
repo_editor: $(FSTOOLS_TAG)
ifeq ($(PODMAN_BUILD),1)
$(PODMAN_RUN) make $@
else
CONFIG=$(FILESYSTEM_CONFIG) $(REPO_BIN)_editor
endif
fstools_clean: FORCE
rm -rf target
rm -rf $(FSTOOLS)

View File

@ -1,15 +1,14 @@
use ansi_to_tui::IntoText;
use cookbook::config::{CookConfig, CookLockOpt, get_config, init_config};
use cookbook::cook::cook_build::{build, get_stage_dirs, remove_stage_dir};
use cookbook::cook::fetch::{FetchResult, fetch, fetch_offline};
use cookbook::cook::fs::{
create_dir, create_target_dir, get_git_commit_date, get_git_head_rev, get_git_rev_before_date,
read_toml, remove_all, run_command,
remove_all, run_command,
};
use cookbook::cook::package::{package, package_handle_push};
use cookbook::cook::pty::{PtyOut, UnixSlavePty, flush_pty, setup_pty, write_to_pty};
use cookbook::cook::script::KILL_ALL_PID;
use cookbook::cook::tree::{self, WalkTreeEntry};
use cookbook::cook::tui::{drain_buffer_to_lines, join_logs, kill_everything, render_build_log};
use cookbook::cook::{fetch_repo, ident};
use cookbook::recipe::{
CookRecipe, SourceRecipe, recipes_flatten_package_names, recipes_mark_as_deps,
@ -20,11 +19,11 @@ use ratatui::Terminal;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::prelude::TermionBackend;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use redox_installer::PackageConfig;
use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::{Read, Write, stderr, stdin, stdout};
use std::path::PathBuf;
use std::process::Command;
@ -32,7 +31,7 @@ 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::{env, fs};
use std::{process, thread};
use termion::event::{Event, Key};
use termion::input::TermRead;
@ -151,6 +150,8 @@ impl FromStr for CliCommand {
match s {
"fetch" => Ok(CliCommand::Fetch),
"cook" => Ok(CliCommand::Cook),
// alias for repo_editor
"rebuild" => Ok(CliCommand::Cook),
"unfetch" => Ok(CliCommand::Unfetch),
"clean" => Ok(CliCommand::Clean),
"clean-target" => Ok(CliCommand::CleanTarget),
@ -519,7 +520,16 @@ fn parse_args(args: Vec<String>) -> Result<(CliConfig, CliCommand, Vec<CookRecip
let Some(command) = command else {
bail_options_err!("Error: No command specified");
};
let command: CliCommand = str::parse(&command)?;
let command =
if command.starts_with("change-rule ") || command.starts_with("change-rule-local ") {
// repo_editor hack
let mut split = command.split(' ');
let cmd: CliCommand = str::parse(split.next().unwrap())?;
config.set_rule = split.next().map(|s| s.to_string());
cmd
} else {
str::parse(&command)?
};
if command.is_informational() {
// avoid extra data that clobber stdout
config.cook.verbose = false;
@ -532,23 +542,11 @@ fn parse_args(args: Vec<String>) -> Result<(CliConfig, CliCommand, Vec<CookRecip
let all_recipes_path = match command.is_cleaning() || config.category.is_some() {
true => {
// some wip recipes have broken toml
staged_pkg::list("")
staged_pkg::list()
}
false => {
// get the list from repo/TARGET/repo.toml
let repo_toml_path = config.repo_dir.join(redoxer::target()).join("repo.toml");
if !repo_toml_path.is_file() {
bail_options_err!(
"The repository is not found: {}",
repo_toml_path.display()
);
};
let repos: pkg::Repository = read_toml(&repo_toml_path)?;
repos
.packages
.keys()
.filter_map(|n| staged_pkg::find(n).map(|s| s.to_path_buf()))
.collect::<BTreeSet<_>>()
staged_pkg::list_repo(&config.repo_dir)?
}
};
@ -906,6 +904,12 @@ fn handle_clean(recipe: &CookRecipe, config: &CliConfig, command: &CliCommand) -
static PUSH_CONFIG: OnceLock<CliConfig> = OnceLock::new();
fn handle_push(recipes: &Vec<CookRecipe>, config: &CliConfig) -> Result<()> {
if !config.sysroot_dir.is_dir() {
return Err(Error::Other(format!(
"{} is not exist. Please run `make mount` first.",
config.sysroot_dir.display()
)));
}
let recipe_map: HashMap<&PackageName, &CookRecipe> =
recipes.iter().map(|r| (&r.name, r)).collect();
let mut total_size: u64 = 0;
@ -1366,14 +1370,7 @@ impl TuiApp {
let _ = std::io::stdout().write_all(&chunk);
}
let log_list = self.logs.entry(name.clone()).or_default();
// TODO: multibyte-aware line split?
while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
let line_bytes = buffer.drain(..=newline_pos);
let line_str = String::from_utf8_lossy(&line_bytes.as_slice());
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());
}
drain_buffer_to_lines(buffer, log_list);
return;
}
StatusUpdate::FlushLog(name, path) => {
@ -1734,84 +1731,24 @@ fn run_tui_cook(config: CliConfig, recipes: Vec<CookRecipe>) -> Result<TuiApp> {
format!(" {} Log ", app.log_view_job.to_string())
};
let mut enable_auto_scroll = false;
let mut intended_scroll_pos = 0usize;
let mut enable_auto_scroll = app.auto_scroll;
let mut intended_scroll_pos = app.log_scroll;
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
}
let log_lines = render_build_log(
panel_height,
log_text,
log_line,
app.prompt.is_none() || config.cook.nonstop,
&mut enable_auto_scroll,
&mut intended_scroll_pos,
if let Some(search_results) = app.search_results.as_ref()
&& app.is_inspecting
{
Some((app.search_idx, search_results))
} 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()
.enumerate()
.map(|(i, s)| {
if app.is_inspecting {
if let Some(search_results) = app.search_results.as_ref() {
let absolute_i = start + i;
if absolute_i == search_results[app.search_idx] {
let s = strip_ansi_escapes::strip_str(s);
return Line::from(s).style(
Style::default().bg(Color::Yellow).fg(Color::Black),
);
} else if search_results.binary_search(&absolute_i).is_ok() {
let s = strip_ansi_escapes::strip_str(s);
return Line::from(s)
.style(Style::default().bg(Color::Black).fg(Color::White));
}
}
}
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);
}
}
None
},
);
let instruct = if app.is_inspecting {
let line_info = if let Some(search_results) = app.search_results.as_ref() {
@ -1876,10 +1813,10 @@ fn run_tui_cook(config: CliConfig, recipes: Vec<CookRecipe>) -> Result<TuiApp> {
draw_prompt(f, prompt, config.cook.nonstop);
}
}
if enable_auto_scroll {
if !app.auto_scroll && enable_auto_scroll {
app.auto_scroll = true;
}
if intended_scroll_pos > 0 {
if intended_scroll_pos != app.log_scroll {
app.log_scroll = intended_scroll_pos;
}
@ -1928,7 +1865,7 @@ fn run_tui_cook(config: CliConfig, recipes: Vec<CookRecipe>) -> Result<TuiApp> {
let _ = stdout().flush();
if config.cook.nonstop && app.dump_logs_on_exit.is_some() {
kill_everything();
kill_everything(None);
}
let _ = fetcher_handle.join();
@ -1937,20 +1874,6 @@ fn run_tui_cook(config: CliConfig, recipes: Vec<CookRecipe>) -> Result<TuiApp> {
Ok(app)
}
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 {
@ -1962,7 +1885,7 @@ fn handle_main_event(app: &mut TuiApp, event: &Event) {
}
Key::Char('c') => {
// as compilers still running, we use this way to stop it
kill_everything();
kill_everything(None);
}
Key::Up => {
app.auto_scroll = false;
@ -2118,17 +2041,6 @@ fn handle_inspect_event(event: &Event, app: &mut TuiApp) -> bool {
false
}
fn kill_everything() {
let pid = std::process::id();
Command::new("bash")
.arg("-c")
.arg(KILL_ALL_PID.replace("$PID", &pid.to_string()))
.stdout(process::Stdio::null())
.stderr(process::Stdio::null())
.spawn()
.expect("unable to spawn kill");
}
fn handle_prompt_input<'a>(
event: &Event,
app: &'a mut TuiApp,

289
src/bin/repo_editor/app.rs Normal file
View File

@ -0,0 +1,289 @@
// Contains code related to TUI model
use crate::*;
#[derive(PartialEq)]
pub enum RightPanelTab {
MainActions,
Terminal(usize),
}
#[derive(PartialEq, Clone, Copy)]
pub enum FilterSource {
Config,
Compiled,
Pinned,
All,
}
impl FilterSource {
pub fn labels() -> Vec<&'static str> {
vec!["[F2] Selected", "[F3] Config", "[F4] Compiled", "[F5] All"]
}
pub fn index(&self) -> usize {
match self {
FilterSource::Pinned => 0,
FilterSource::Config => 1,
FilterSource::Compiled => 2,
FilterSource::All => 3,
}
}
pub const fn try_from_index(i: usize) -> Option<FilterSource> {
Some(match i {
0 => FilterSource::Pinned,
1 => FilterSource::Config,
2 => FilterSource::Compiled,
3 => FilterSource::All,
_ => return None,
})
}
}
#[derive(PartialEq)]
pub enum Focus {
SearchAndList,
MainPanel,
}
pub struct ActionButton {
pub key: char,
pub label: &'static str,
pub cmd: &'static str,
pub area: Rect,
}
pub struct App {
pub search_query: String,
pub all_recipes: Vec<CookRecipe>,
pub all_compiled_recipes: Vec<CookRecipe>,
pub all_config_recipes: Vec<CookRecipe>,
pub filtered_recipes: Vec<PackageName>,
pub pinned_recipes: BTreeSet<PackageName>,
pub filter_source: FilterSource,
pub list_state: ListState,
pub focus: Focus,
pub buttons: Vec<ActionButton>,
pub last_click_time: Option<Instant>,
pub last_click_idx: Option<usize>,
pub filter_tabs_area: Rect,
pub right_tabs_area: Rect,
pub right_tabs_str: Vec<String>,
pub right_tab_active: RightPanelTab,
pub exec: ExecutionManager,
}
impl App {
pub fn new() -> Self {
let mut app = Self {
search_query: String::new(),
all_recipes: Vec::new(),
all_compiled_recipes: Vec::new(),
all_config_recipes: Vec::new(),
filtered_recipes: Vec::new(),
pinned_recipes: BTreeSet::new(),
filter_source: FilterSource::Config,
list_state: ListState::default(),
right_tabs_area: Rect::default(),
right_tabs_str: Vec::new(),
focus: Focus::SearchAndList,
buttons: vec![
ActionButton {
key: 'f',
label: "[F]etch",
cmd: "fetch",
area: Rect::default(),
},
ActionButton {
key: 'u',
label: "[U]nfetch",
cmd: "unfetch",
area: Rect::default(),
},
ActionButton {
key: 'c',
label: "[C]lean",
cmd: "clean",
area: Rect::default(),
},
ActionButton {
key: 'r',
label: "[R]ebuild",
cmd: "rebuild",
area: Rect::default(),
},
ActionButton {
key: 'p',
label: "[P]ush",
cmd: "push",
area: Rect::default(),
},
],
last_click_time: None,
last_click_idx: None,
filter_tabs_area: Rect::default(),
right_tab_active: RightPanelTab::MainActions,
exec: ExecutionManager::new(),
};
app.read_recipes();
app.update_filter();
app
}
pub fn read_recipes(&mut self) {
self.all_recipes = staged_pkg::list()
.iter()
.filter_map(|p| CookRecipe::from_path(p, false, false).ok())
.collect();
self.all_compiled_recipes = staged_pkg::list_repo("repo")
.unwrap()
.iter()
.filter_map(|p| CookRecipe::from_path(p, false, false).ok())
.collect();
self.all_config_recipes = std::env::var("CONFIG")
.ok()
.and_then(|s| redox_installer::Config::from_file(Path::new(&s)).ok())
.map(|s| {
s.packages
.keys()
.filter_map(|p| PackageName::new(p).ok())
.filter_map(|p| CookRecipe::from_name(p).ok())
.collect()
})
.unwrap_or(vec![]);
}
pub fn source_filter(&self) -> &Vec<CookRecipe> {
match self.filter_source {
FilterSource::Config => &self.all_config_recipes,
FilterSource::Compiled => &self.all_compiled_recipes,
FilterSource::All => &self.all_recipes,
_ => todo!(),
}
}
pub fn update_filter(&mut self) {
let query = self.search_query.to_lowercase();
if matches!(self.filter_source, FilterSource::Pinned) {
self.filtered_recipes = self.pinned_recipes.iter().cloned().collect();
} else {
self.filtered_recipes = self
.source_filter()
.iter()
.filter(|p| p.name.as_str().contains(&query))
.map(|r| r.name.clone())
.collect();
}
self.filtered_recipes.sort();
if self.filtered_recipes.is_empty() {
self.list_state.select(None);
} else if self.list_state.selected().is_none() {
self.list_state.select(Some(0));
} else if let Some(idx) = self.list_state.selected() {
if idx >= self.filtered_recipes.len() {
self.list_state
.select(Some(self.filtered_recipes.len() - 1));
}
}
self.right_tab_active = RightPanelTab::MainActions;
}
pub fn highlighted_recipe(&self) -> Option<&PackageName> {
self.list_state
.selected()
.and_then(|idx| self.filtered_recipes.get(idx))
}
pub fn selected_recipes(&self) -> Vec<PackageName> {
if !self.pinned_recipes.is_empty() {
let mut targets: Vec<_> = self.pinned_recipes.iter().cloned().collect();
targets.sort();
targets
} else if let Some(single) = self.highlighted_recipe() {
vec![single.clone()]
} else {
Vec::new()
}
}
pub fn toggle_pinned_highlighted(&mut self) {
if let Some(recipe) = self.highlighted_recipe().cloned() {
if self.pinned_recipes.contains(&recipe) {
self.pinned_recipes.remove(&recipe);
} else {
self.pinned_recipes.insert(recipe);
}
}
}
pub fn pin_all_filtered(&mut self) {
let mut nothing_pinned = true;
for recipe in &self.filtered_recipes {
nothing_pinned &= !self.pinned_recipes.insert(recipe.clone());
}
if nothing_pinned {
for recipe in &self.filtered_recipes {
self.pinned_recipes.remove(recipe);
}
}
}
pub fn box_style<'a, 'b>(&'b self, title: &'a str, hint: &'a str, focus: Focus) -> Block<'a> {
let style = if self.focus == focus {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
Block::default()
.borders(Borders::ALL)
.title(title)
.title_bottom(hint)
.border_style(style)
}
pub fn select_delta(&mut self, delta: isize, wrap: bool) {
let len = self.filtered_recipes.len();
if len == 0 {
return;
}
let current = self.list_state.selected().unwrap_or(0);
let next = current.saturating_add_signed(delta);
let next = if wrap {
next % len
} else {
next.clamp(0, len - 1)
};
self.list_state.select(Some(next));
self.focus = Focus::SearchAndList;
self.right_tab_active = RightPanelTab::MainActions;
}
pub fn current_job_id(&self) -> Option<JobId> {
let RightPanelTab::Terminal(i) = self.right_tab_active else {
return None;
};
self.exec.active_job_order.get(i).map(|s| *s)
}
pub fn current_job(&self) -> Option<&PackageJob> {
let Some(i) = self.current_job_id() else {
return None;
};
self.exec.jobs.get(&i)
}
pub fn current_job_mut(&mut self) -> Option<&mut PackageJob> {
let Some(i) = self.current_job_id() else {
return None;
};
self.exec.jobs.get_mut(&i)
}
}

211
src/bin/repo_editor/jobs.rs Normal file
View File

@ -0,0 +1,211 @@
// Contains code related to background tasks
use std::fmt::{Display as FmtDisplay, Write as FmtWrite};
use std::io::Read;
use std::sync::mpsc;
use std::thread;
use std::{collections::HashMap, io::Write};
use cookbook::cook::pty::{setup_pty_with_stdin, spawn_to_pipe};
use cookbook::cook::tui::drain_buffer_to_lines;
use pkg::PackageName;
pub type JobId = u64;
pub enum StatusUpdate {
PushLog(JobId, Vec<u8>),
JobFinished(JobId, i32),
}
pub struct PackageJob {
pub id: JobId,
pub command: String,
pub targets: Vec<PackageName>,
pub logs: Vec<String>,
pub buffer: Vec<u8>,
pub scroll: usize,
pub auto_scroll: bool,
pub exit_code: Option<i32>,
pub pid: Option<u32>,
pub pty_writer: Box<dyn Write + Send>,
}
impl PackageJob {
pub fn write_input(&mut self, bytes: &[u8]) {
let _ = self.pty_writer.write_all(bytes);
let _ = self.pty_writer.flush();
}
}
impl FmtDisplay for PackageJob {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.command)?;
f.write_char(' ')?;
match self.targets.len() {
0 => unreachable!(),
1 => f.write_str(self.targets[0].as_str())?,
2 => write!(
f,
"{} & {}",
self.targets[0].as_str(),
self.targets[0].as_str()
)?,
_ => write!(
f,
"{} ({} more)",
self.targets[0].as_str(),
self.targets.len() - 1
)?,
}
match self.exit_code {
Some(0) => f.write_str(" ✅")?,
Some(_) => f.write_str(" ❌")?,
None => {}
}
Ok(())
}
}
pub struct ExecutionManager {
next_job_id: JobId,
pub jobs: HashMap<JobId, PackageJob>,
pub active_job_order: Vec<JobId>,
}
impl ExecutionManager {
pub fn new() -> Self {
Self {
next_job_id: 1,
jobs: HashMap::new(),
active_job_order: Vec::new(),
}
}
pub fn spawn_job(
&mut self,
command: &str,
targets: Vec<PackageName>,
status_tx: mpsc::Sender<StatusUpdate>,
) {
if targets.is_empty() {
return;
}
// Scan anything not failed to auto close
if self.active_job_order.len() > 0 {
for id in self.active_job_order.clone() {
if self
.jobs
.get(&id)
.is_some_and(|s| s.exit_code.is_some_and(|s| s == 0))
{
self.close_job(&id);
}
}
}
let job_id = self.next_job_id;
self.next_job_id += 1;
let (pty_reader, log_reader, pty_writer, (mut slave_pty, mut pipe_writer)) =
setup_pty_with_stdin();
let mut job = PackageJob {
id: job_id,
command: command.to_string(),
targets: targets.clone(),
logs: Vec::new(),
buffer: Vec::new(),
scroll: 0,
auto_scroll: true,
exit_code: None,
pid: None,
pty_writer,
};
spawn_log_reader(pty_reader, job_id, status_tx.clone());
spawn_log_reader(log_reader, job_id, status_tx.clone());
let mut cmd = std::process::Command::new(
std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.join("repo"),
);
cmd.arg(command);
for target in &targets {
cmd.arg(target.as_str());
}
// TODO: How to handle more ANSI code?
cmd.env("CI", "1");
self.active_job_order.push(job_id);
let pipe = (&mut slave_pty, &mut pipe_writer);
let mut handle = spawn_to_pipe(&mut cmd, &Some(pipe)).unwrap();
job.pid = Some(handle.id());
self.jobs.insert(job_id, job);
let _ = write!(pipe_writer, "{:?}\n", cmd);
thread::spawn(move || {
let code = handle.wait().unwrap().code().unwrap_or(-1);
status_tx
.send(StatusUpdate::JobFinished(job_id, code))
.unwrap();
});
}
pub fn handle_status_update(&mut self, update: StatusUpdate) {
match update {
StatusUpdate::PushLog(job_id, bytes) => {
if let Some(job) = self.jobs.get_mut(&job_id) {
job.buffer.extend_from_slice(&bytes);
let rel = drain_buffer_to_lines(&mut job.buffer, &mut job.logs);
if job.auto_scroll {
job.scroll += rel;
}
}
}
StatusUpdate::JobFinished(job_id, code) => {
if let Some(job) = self.jobs.get_mut(&job_id) {
job.pid.take();
job.exit_code = Some(code);
}
}
}
}
pub fn close_job(&mut self, id: &JobId) {
self.jobs.remove(id);
if let Some(id) = self.active_job_order.iter().position(|s| *s == *id) {
self.active_job_order.remove(id);
}
}
}
fn spawn_log_reader<R>(mut reader: R, job_id: JobId, status_tx: mpsc::Sender<StatusUpdate>)
where
R: Read + Send + 'static,
{
thread::spawn(move || {
let mut buffer = [0; 1024];
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(n) => {
if status_tx
.send(StatusUpdate::PushLog(job_id, buffer[..n].to_vec()))
.is_err()
{
break;
}
}
Err(e) => {
let err_msg = format!("[IO Error] {}", e).into_bytes();
let _ = status_tx.send(StatusUpdate::PushLog(job_id, err_msg));
break;
}
}
}
});
}

584
src/bin/repo_editor/main.rs Normal file
View File

@ -0,0 +1,584 @@
// Contains code related to TUI layout
use cookbook::{
Error, Result,
cook::tui::{get_clicked_tab_index, kill_everything, render_build_log},
recipe::CookRecipe,
staged_pkg,
};
use pkg::PackageName;
use ratatui::{
Terminal,
backend::TermionBackend,
layout::{Constraint, Direction, Layout, Position, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap},
};
use std::collections::BTreeSet;
use std::time::{Duration, Instant};
use std::{
io::{self},
thread,
};
use std::{ops::ControlFlow, path::Path};
use termion::{
event::{Event, Key, MouseButton, MouseEvent},
input::{MouseTerminal, TermRead},
raw::IntoRawMode,
screen::IntoAlternateScreen,
};
use std::sync::mpsc;
pub mod app;
pub use app::*;
pub mod jobs;
pub use jobs::*;
fn main() -> Result<()> {
let mut app = App::new();
let (status_tx, status_rx) = mpsc::channel::<StatusUpdate>();
let stdout = io::stdout().into_raw_mode().unwrap();
let stdout = MouseTerminal::from(stdout);
let stdout = stdout.into_alternate_screen().unwrap();
let backend = TermionBackend::new(stdout);
let mut terminal = Terminal::new(backend).unwrap();
let stdin = io::stdin();
let events = stdin.events();
let (input_tx, input_rx) = mpsc::channel::<Event>();
let _input_handle = thread::spawn(move || {
for evt in events {
if let Ok(evt) = evt {
if input_tx.send(evt).is_err() {
return;
}
}
}
});
loop {
let r = terminal.draw(|f| {
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(30), Constraint::Min(0)])
.split(f.area());
render_left_recipes(&mut app, f, &main_chunks);
let right_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(main_chunks[1]);
render_right_tabs(&mut app, f, &right_chunks);
match app.right_tab_active {
RightPanelTab::MainActions => {
let right_style =
app.box_style(" Main Actions ", "[Ctrl+C] Exit", Focus::MainPanel);
let right_inner = right_style.inner(right_chunks[1]);
f.render_widget(right_style, right_chunks[1]);
let right_chunks_2 = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(25), Constraint::Min(0)])
.split(right_inner);
render_right_actions(&mut app, f, right_chunks_2[0]);
}
RightPanelTab::Terminal(_) => {
render_right_terminal(f, &mut app, right_chunks[1]);
}
}
});
r.map_err(|e| Error::from_io_error(e, "Drawing to terminal pty"))?;
while let Ok(event) = input_rx.try_recv() {
if let ControlFlow::Break(_) = handle_input(&mut app, &mut terminal, event, &status_tx)
{
kill_everything(None);
return Ok(());
}
}
while let Ok(update) = status_rx.try_recv() {
app.exec.handle_status_update(update);
}
std::thread::sleep(Duration::from_millis(16));
}
}
fn render_left_recipes(
app: &mut App,
f: &mut ratatui::prelude::Frame<'_>,
main_chunks: &std::rc::Rc<[Rect]>,
) {
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(2),
Constraint::Min(0),
])
.split(main_chunks[0]);
let search_title = if app.pinned_recipes.len() == 0 {
format!(" Search ")
} else {
format!(" Search [Selected {}] ", app.pinned_recipes.len())
};
let search_style = app.box_style(&search_title, "", Focus::SearchAndList);
let search_box = Paragraph::new(app.search_query.as_str()).block(search_style);
f.render_widget(search_box, left_chunks[0]);
app.filter_tabs_area = left_chunks[1];
{
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(left_chunks[1]);
let labels = FilterSource::labels();
let tabs = Tabs::new(vec![labels[0], labels[1]])
.select(app.filter_source.index())
.style(Style::default().fg(Color::Gray))
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
f.render_widget(tabs, split[0]);
let tabs = Tabs::new(vec![labels[2], labels[3]])
.select(app.filter_source.index() - 2)
.style(Style::default().fg(Color::Gray))
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
f.render_widget(tabs, split[1]);
}
let list_style = app.box_style(" Recipes ", "[Ctrl+A] Select All", Focus::SearchAndList);
let items: Vec<ListItem> = app
.filtered_recipes
.iter()
.map(|name| {
let is_pinned = app.pinned_recipes.contains(name);
let prefix = if is_pinned { "* " } else { " " };
let style = if is_pinned {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(vec![
Span::styled(prefix, Style::default().fg(Color::Green)),
Span::styled(name.as_str(), style),
]))
})
.collect();
let list = List::new(items)
.block(list_style)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">");
f.render_stateful_widget(list, left_chunks[2], &mut app.list_state);
}
fn render_right_tabs(
app: &mut App,
f: &mut ratatui::prelude::Frame<'_>,
right_chunks: &std::rc::Rc<[Rect]>,
) {
let mut right_tab_titles = vec![" [F6] Build ".to_string()];
let mut fbtn = 6;
for job in &app.exec.active_job_order {
let Some(job) = app.exec.jobs.get(&job) else {
continue;
};
let fstr = if fbtn > 12 {
"".into()
} else {
fbtn += 1;
format!("[F{}] ", fbtn)
};
right_tab_titles.push(format!("{}{}", fstr, job));
}
let selected_tab_idx = match &app.right_tab_active {
RightPanelTab::MainActions => 0,
RightPanelTab::Terminal(i) => *i + 1,
};
app.right_tabs_area = right_chunks[0];
let top_right_tabs = Tabs::new(right_tab_titles.clone())
.select(selected_tab_idx)
.style(Style::default().fg(Color::Gray))
.block(app.box_style("", "", Focus::MainPanel))
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
app.right_tabs_str = right_tab_titles
.into_iter()
.map(|s| s.to_string())
.collect();
f.render_widget(top_right_tabs, right_chunks[0]);
}
fn render_right_actions(app: &mut App, f: &mut ratatui::prelude::Frame<'_>, right_layout: Rect) {
let active_targets = app.selected_recipes();
if !active_targets.is_empty() {
let btn_count = app.buttons.len();
let info_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length((btn_count * 3) as _),
])
.split(right_layout);
let target_str = if active_targets.len() == 1 {
active_targets[0].to_string()
} else {
format!("{} recipes", active_targets.len())
};
let info_text = vec![Line::from(vec![
Span::raw("Selected: "),
Span::styled(
target_str,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
])];
f.render_widget(Paragraph::new(info_text), info_chunks[0]);
let btn_constraints = vec![Constraint::Percentage(100 / btn_count as u16); btn_count];
let btn_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(btn_constraints)
.split(info_chunks[1]);
for (idx, btn) in app.buttons.iter_mut().enumerate() {
btn.area = btn_chunks[idx];
let btn_widget = Paragraph::new(btn.label)
.alignment(ratatui::layout::Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Gray)),
);
f.render_widget(btn_widget, btn.area);
}
} else {
let no_select = Paragraph::new("No recipe selected");
f.render_widget(no_select, right_layout);
}
}
fn render_right_terminal(f: &mut ratatui::Frame, app: &mut App, area: Rect) {
let Some(id) = app.current_job_id() else {
return;
};
let is_focus = app.focus == Focus::MainPanel;
let Some(job) = &mut app.exec.jobs.get_mut(&id) else {
return;
};
let title = format!(" Job #{}: repo {}", job.id, job);
let hint = if job.exit_code.is_some() {
"[X] Close [Ctrl+C] Exit"
} else {
"[Ctrl+C] Stop"
};
let auto_scroll = job.auto_scroll;
let lines = render_build_log(
area.height as _,
Some(&job.logs),
Some(String::from_utf8_lossy(&job.buffer)),
true,
&mut job.auto_scroll,
&mut job.scroll,
None,
);
let line_block = {
// TODO: Copied from app.box_style (borrowing issue)
let style = if is_focus {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
Block::default()
.borders(Borders::ALL)
.title(title)
.title_bottom(hint)
.border_style(style)
};
let mut paragraph = Paragraph::new(lines).block(line_block);
if !auto_scroll {
paragraph = paragraph.wrap(Wrap { trim: false });
}
f.render_widget(paragraph, area);
}
fn handle_input<B: ratatui::backend::Backend>(
app: &mut App,
terminal: &mut Terminal<B>,
event: Event,
status_tx: &mpsc::Sender<StatusUpdate>,
) -> ControlFlow<()> {
match event {
Event::Key(Key::Ctrl('c')) => {
if let Some(job) = app.current_job()
&& job.pid.is_some()
{
kill_everything(job.pid);
} else {
return ControlFlow::Break(());
}
}
Event::Key(Key::F(n)) => {
if let Some(fi) = FilterSource::try_from_index((n - 2) as _) {
if app.filter_source != fi {
app.filter_source = fi;
app.update_filter();
}
app.focus = Focus::SearchAndList;
} else if n == 6 {
app.right_tab_active = RightPanelTab::MainActions;
app.focus = Focus::MainPanel;
} else if n != 0 {
app.right_tab_active = RightPanelTab::Terminal((n - 7) as _);
app.focus = Focus::MainPanel;
}
}
Event::Key(Key::Ctrl('a')) => {
app.pin_all_filtered();
}
Event::Key(Key::Ctrl('r')) => {
app.read_recipes();
}
Event::Key(Key::Char('x')) => {
if let Some(id) = app.current_job_id() {
app.exec.close_job(&id);
app.right_tab_active = RightPanelTab::MainActions;
}
}
Event::Key(key) => match app.focus {
Focus::SearchAndList => match key {
Key::Char('\t') => {
app.toggle_pinned_highlighted();
}
Key::Char('\n') => {
app.focus = Focus::MainPanel;
}
Key::Char(c) => {
app.search_query.push(c);
app.update_filter();
}
Key::Backspace => {
app.search_query.pop();
app.update_filter();
}
Key::Up => app.select_delta(-1, true),
Key::Down => app.select_delta(1, true),
_ => {}
},
Focus::MainPanel => match key {
Key::Char('\n') | Key::Esc => {
app.focus = Focus::SearchAndList;
}
Key::Char('\t') => {
app.toggle_pinned_highlighted();
}
Key::Down => app.select_delta(1, true),
Key::Char(c) => {
let matched_action = app.buttons.iter().find(|b| b.key == c).map(|b| b.cmd);
let targets = app.selected_recipes();
if let (Some(cmd), false, true) = (
matched_action,
targets.is_empty(),
app.current_job_id().is_none(),
) {
app.exec.spawn_job(cmd, targets, status_tx.clone());
app.right_tab_active =
RightPanelTab::Terminal(app.exec.active_job_order.len() - 1);
}
}
key => {
if let Some(job) = app.current_job_mut() {
match key {
Key::Up => {
job.auto_scroll = false;
job.scroll = job.scroll.saturating_add_signed(-1);
}
Key::Down => {
job.auto_scroll = false;
job.scroll = job.scroll.saturating_add_signed(1);
}
Key::PageUp => {
job.auto_scroll = false;
job.scroll = job.scroll.saturating_add_signed(-20);
}
Key::PageDown => {
job.auto_scroll = false;
job.scroll = job.scroll.saturating_add_signed(20);
}
Key::Home => {
job.auto_scroll = false;
job.scroll = 0;
}
Key::End => {
job.auto_scroll = true;
job.scroll = usize::MAX;
}
_ => {}
}
}
}
},
},
Event::Mouse(mouse_event) => {
let (x, y) = match mouse_event {
MouseEvent::Press(_, x, y) => (x, y),
MouseEvent::Hold(x, y) => (x, y),
_ => (0, 0),
};
let mx = x.saturating_sub(1);
let my = y.saturating_sub(1);
let mp = Position::new(mx, my);
let size = terminal.size().unwrap();
let viewport = Rect::new(0, 0, size.width, size.height);
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(30), Constraint::Min(0)])
.split(viewport);
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(2),
Constraint::Min(0),
])
.split(main_chunks[0]);
match mouse_event {
MouseEvent::Press(
btn @ MouseButton::WheelUp | btn @ MouseButton::WheelDown,
_,
_,
) => {
let delta = if btn == MouseButton::WheelUp { -1 } else { 1 };
if main_chunks[0].contains(mp) {
app.focus = Focus::SearchAndList;
app.select_delta(delta, false);
} else {
app.focus = Focus::MainPanel;
if let Some(i) = app.current_job_mut() {
i.scroll = i.scroll.saturating_add_signed(delta);
i.auto_scroll = false;
}
}
}
MouseEvent::Press(MouseButton::Left, _, _) | MouseEvent::Hold(_, _) => {
if app.filter_tabs_area.contains(mp) {
let tabw = app.filter_tabs_area.width / 2;
let tabh = app.filter_tabs_area.height / 2;
let relative_x = mx.saturating_sub(app.filter_tabs_area.x);
let relative_y = my.saturating_sub(app.filter_tabs_area.y);
let index = (relative_x / tabw) + (relative_y / tabh * 2);
app.filter_source =
FilterSource::try_from_index(index as _).unwrap_or(FilterSource::All);
app.update_filter();
} else if app.right_tabs_area.contains(mp) {
app.focus = Focus::MainPanel;
if let Some(i) =
get_clicked_tab_index(app.right_tabs_area, mp, &app.right_tabs_str, 3)
{
if i == 0 {
app.right_tab_active = RightPanelTab::MainActions;
} else {
app.right_tab_active = RightPanelTab::Terminal(i - 1);
}
}
} else if left_chunks[2].contains(mp) {
app.focus = Focus::SearchAndList;
let list_top = left_chunks[2].y + 1;
if my >= list_top && my < left_chunks[2].y + left_chunks[2].height - 1 {
let clicked_visible_row = (my - list_top) as usize;
let offset = app.list_state.offset();
let target_idx = offset + clicked_visible_row;
if target_idx < app.filtered_recipes.len() {
app.list_state.select(Some(target_idx));
app.right_tab_active = RightPanelTab::MainActions;
let now = Instant::now();
if let (Some(last_time), Some(last_idx)) =
(app.last_click_time, app.last_click_idx)
{
if last_idx == target_idx
&& now.duration_since(last_time)
< Duration::from_millis(300)
{
app.toggle_pinned_highlighted();
}
}
app.last_click_time = Some(now);
app.last_click_idx = Some(target_idx);
}
}
} else if main_chunks[1].contains(mp) {
app.focus = Focus::MainPanel;
if app.current_job_id().is_none() {}
let mut clicked_command = None;
for btn in &app.buttons {
if btn.area.contains(mp) {
clicked_command = Some(btn.cmd);
break;
}
}
let targets = app.selected_recipes();
if let Some(cmd) = clicked_command
&& !targets.is_empty()
&& app.current_job_id().is_none()
{
app.exec.spawn_job(cmd, targets, status_tx.clone());
app.right_tab_active =
RightPanelTab::Terminal(app.exec.active_job_order.len() - 1);
}
}
}
_ => {}
};
}
_ => {}
}
ControlFlow::Continue(())
}

View File

@ -8,3 +8,4 @@ pub mod package;
pub mod pty;
pub mod script;
pub mod tree;
pub mod tui;

View File

@ -24,6 +24,8 @@ pub(crate) use log_to_pty;
pub type PtyOut<'a> = Option<(&'a mut UnixSlavePty, &'a mut PipeWriter)>;
/// Setup pty without stdin.
/// Returns (pty stdout, log out, pty+log writer)
pub fn setup_pty() -> (
Box<dyn Read + Send>,
PipeReader,
@ -38,7 +40,6 @@ pub fn setup_pty() -> (
})
.expect("Unable to open pty");
// TODO: There's no way to handle stdin
let pty_reader = pair
.master
.try_clone_reader()
@ -49,6 +50,41 @@ pub fn setup_pty() -> (
(pty_reader, log_reader, pipes)
}
/// Setup pty with stdin.
/// Returns (pty stdout, log out, pty stdin, pty+log writer)
pub fn setup_pty_with_stdin() -> (
Box<dyn Read + Send>,
PipeReader,
Box<dyn Write + Send>,
(UnixSlavePty, PipeWriter),
) {
let pty_system = UnixPtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.expect("Unable to open pty");
let pty_reader = pair
.master
.try_clone_reader()
.expect("Unable to clone pty reader");
let pty_writer = pair
.master
.try_clone_writer()
.expect("Unable to clone pty writer");
let (log_reader, log_writer) = std::io::pipe().expect("Failed to create log pipe");
let pipes = (pair.slave, log_writer);
(pty_reader, log_reader, pty_writer, pipes)
}
pub fn flush_pty(logger: &mut PtyOut) {
let Some((pty, file)) = logger else {
return;
@ -66,6 +102,13 @@ pub fn spawn_to_pipe(command: &mut Command, stdout_pipe: &PtyOut) -> Result<Chil
}
}
pub fn spawn_to_pipe_with_stdin(command: &mut Command, stdout_pipe: &PtyOut) -> Result<Child> {
match stdout_pipe {
Some(stdout) => stdout.0.spawn_command_stdin(command.into()),
None => Ok(command.spawn().map_err(wrap_io_err!("Spawning"))?),
}
}
pub fn write_to_pty(pty: &PtyOut, text: &str) {
log_to_pty!(pty, "{}", text);
}
@ -188,6 +231,16 @@ impl Read for PtyFd {
}
}
impl Write for PtyFd {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.flush()
}
}
impl PtyFd {
fn resize(&self, size: PtySize) -> Result<()> {
let ws_size = winsize {
@ -231,37 +284,40 @@ impl PtyFd {
})
}
fn spawn_command(&self, cmd: &mut Command) -> Result<std::process::Child> {
fn spawn_command(&self, cmd: &mut Command, stdin: bool) -> Result<std::process::Child> {
cmd.stdout(self.try_clone().map_err(wrap_io_err!("Cloning pty"))?)
.stderr(self.try_clone().map_err(wrap_io_err!("Cloning pty"))?);
if stdin {
cmd.stdin(self.try_clone().map_err(wrap_io_err!("Cloning pty"))?);
}
unsafe {
cmd
// .stdin(self.as_stdio()?)
.stdout(self.try_clone().map_err(wrap_io_err!("Cloning pty"))?)
.stderr(self.try_clone().map_err(wrap_io_err!("Cloning pty"))?)
.pre_exec(move || {
// Clean up a few things before we exec the program
// Clear out any potentially problematic signal
// dispositions that we might have inherited
for signo in &[
libc::SIGCHLD,
libc::SIGHUP,
libc::SIGINT,
libc::SIGQUIT,
libc::SIGTERM,
libc::SIGALRM,
] {
libc::signal(*signo, libc::SIG_DFL);
}
cmd.pre_exec(move || {
// Clean up a few things before we exec the program
// Clear out any potentially problematic signal
// dispositions that we might have inherited
for signo in &[
libc::SIGCHLD,
libc::SIGHUP,
libc::SIGINT,
libc::SIGQUIT,
libc::SIGTERM,
libc::SIGALRM,
] {
libc::signal(*signo, libc::SIG_DFL);
}
let empty_set: libc::sigset_t = std::mem::zeroed();
libc::sigprocmask(libc::SIG_SETMASK, &empty_set, std::ptr::null_mut());
let empty_set: libc::sigset_t = std::mem::zeroed();
libc::sigprocmask(libc::SIG_SETMASK, &empty_set, std::ptr::null_mut());
// Establish ourselves as a session leader.
if libc::setsid() == -1 {
return Err(io::Error::last_os_error());
}
// Establish ourselves as a session leader.
if libc::setsid() == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
})
Ok(())
})
};
let mut child = cmd.spawn().map_err(wrap_io_err!("Spawning cmd"))?;
@ -271,7 +327,9 @@ impl PtyFd {
// them) and won't work in the usual way anyway.
// In practice these are None, but it seems best to be move them
// out in case the behavior of Command changes in the future.
// child.stdin.take();
if stdin {
child.stdin.take();
}
child.stdout.take();
child.stderr.take();
@ -310,7 +368,10 @@ fn cloexec(fd: RawFd) -> Result<()> {
impl UnixSlavePty {
fn spawn_command(&self, builder: &mut Command) -> Result<std::process::Child> {
Ok(self.fd.spawn_command(builder)?)
Ok(self.fd.spawn_command(builder, false)?)
}
fn spawn_command_stdin(&self, builder: &mut Command) -> Result<std::process::Child> {
Ok(self.fd.spawn_command(builder, true)?)
}
fn flush(&mut self) -> Result<()> {
self.fd.flush()
@ -336,4 +397,13 @@ impl UnixMasterPty {
);
Ok(Box::new(fd))
}
pub fn try_clone_writer(&self) -> Result<Box<dyn Write + Send>> {
let fd = PtyFd(
self.fd
.try_clone()
.map_err(wrap_io_err!("Cloning pty fd"))?,
);
Ok(Box::new(fd))
}
}

174
src/cook/tui.rs Normal file
View File

@ -0,0 +1,174 @@
// TUI utilities, shared between repo and repo_editor
use ansi_to_tui::IntoText;
use std::{borrow::Cow, cmp, process};
use ratatui::{
layout::{Position, Rect},
style::{Color, Style},
text::{Line, Text},
};
use crate::cook::script::KILL_ALL_PID;
/// Drain `buffer` to `lines` when new lines occurred. Does `"\r"` trimming.
pub fn drain_buffer_to_lines(buffer: &mut Vec<u8>, lines: &mut Vec<String>) -> usize {
let mut addition = 0;
// TODO: multibyte-aware line split?
while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
let line_bytes = buffer.drain(..=newline_pos);
let line_str = String::from_utf8_lossy(&line_bytes.as_slice());
let line_str_pos = line_str.trim_end();
let line_str = line_str_pos.rsplit('\r').next().unwrap_or(&line_str_pos);
lines.push(line_str.to_owned());
addition += 1;
}
addition
}
/// Render build log lines, stateful
pub fn render_build_log<'a>(
panel_height: usize,
log_text: Option<&'a Vec<String>>,
log_line: Option<Cow<'a, str>>,
may_autoscroll: bool,
enable_auto_scroll: &'a mut bool,
intended_scroll_pos: &'a mut usize,
search_result: Option<(usize, &'a Vec<usize>)>,
) -> Vec<Line<'a>> {
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 *enable_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 *intended_scroll_pos >= total_log_lines - limit {
if may_autoscroll {
*enable_auto_scroll = true;
}
*intended_scroll_pos = total_log_lines - limit;
total_log_lines - limit
} else {
*intended_scroll_pos
}
} else {
0
}
};
let end = cmp::min(panel_height + start, total_log_lines - 1);
log_text[start..end]
.iter()
.enumerate()
.map(|(i, s)| {
if let Some((search_idx, search_results)) = search_result {
let absolute_i = start + i;
if absolute_i == search_results[search_idx] {
let s = strip_ansi_escapes::strip_str(s);
return Line::from(s)
.style(Style::default().bg(Color::Yellow).fg(Color::Black));
} else if search_results.binary_search(&absolute_i).is_ok() {
let s = strip_ansi_escapes::strip_str(s);
return Line::from(s)
.style(Style::default().bg(Color::Black).fg(Color::White));
}
}
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![]
};
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);
}
}
if log_lines.is_empty() {
log_lines.push(Line::from("No logs yet"));
}
log_lines
}
/// Join logs for logging
pub 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)
}
/// Check if point is in rect
pub fn get_clicked_tab_index(
area: Rect,
mp: Position,
titles: &Vec<String>,
divider_len: u16,
) -> Option<usize> {
if !area.contains(mp) {
return None;
}
let mut current_x = area.x;
let mx = mp.x;
for (idx, title) in titles.iter().enumerate() {
let title_width = title.chars().count() as u16;
let tab_end_x = current_x + title_width;
if mx >= current_x && mx < tab_end_x {
return Some(idx);
}
current_x = tab_end_x + divider_len;
if current_x >= area.x + area.width {
break;
}
}
None
}
/// Kill everything under given PID or this process PID.
pub fn kill_everything(pid: Option<u32>) {
process::Command::new("bash")
.arg("-c")
.arg(KILL_ALL_PID.replace("$PID", &pid.unwrap_or_else(|| process::id()).to_string()))
.stdout(process::Stdio::null())
.stderr(process::Stdio::null())
.spawn()
.expect("unable to spawn kill");
}

View File

@ -6,6 +6,9 @@ use std::sync::LazyLock;
use pkg::{Package, PackageError, PackageName};
use crate::bail_other_err;
use crate::cook::fs;
// This file contains code that caches recipe paths.
// TODO: This file is previously resides in `pkg` crate,
@ -45,12 +48,22 @@ pub fn find(recipe: &str) -> Option<&'static Path> {
RECIPE_PATHS.get(recipe).map(PathBuf::as_path)
}
pub fn list(prefix: impl AsRef<Path>) -> BTreeSet<PathBuf> {
let prefix = prefix.as_ref();
RECIPE_PATHS
.values()
.map(|path| prefix.join(path))
.collect()
pub fn list() -> BTreeSet<PathBuf> {
RECIPE_PATHS.values().map(|p| p.to_path_buf()).collect()
}
pub fn list_repo(repo_dir: impl AsRef<Path>) -> crate::Result<BTreeSet<PathBuf>> {
let repo_dir = repo_dir.as_ref();
let repo_toml_path = repo_dir.join(redoxer::target()).join("repo.toml");
if !repo_toml_path.is_file() {
bail_other_err!("The repository is not found: {}", repo_toml_path.display());
};
let repos: pkg::Repository = fs::read_toml(&repo_toml_path)?;
Ok(repos
.packages
.keys()
.filter_map(|n| find(n).map(|s| s.to_path_buf()))
.collect::<BTreeSet<_>>())
}
pub fn new(name: &PackageName) -> Result<Package, PackageError> {