From ff9a54879583b48c0441489d59868a7b4b69b3d8 Mon Sep 17 00:00:00 2001 From: Wildan M Date: Sun, 26 Jul 2026 14:43:36 +0700 Subject: [PATCH] Implement new repo_editor --- .gitlab-ci.yml | 2 + Cargo.toml | 4 + mk/fstools.mk | 7 + src/bin/repo.rs | 180 +++-------- src/bin/repo_editor/app.rs | 289 ++++++++++++++++++ src/bin/repo_editor/jobs.rs | 211 +++++++++++++ src/bin/repo_editor/main.rs | 584 ++++++++++++++++++++++++++++++++++++ src/cook.rs | 1 + src/cook/pty.rs | 130 ++++++-- src/cook/tui.rs | 174 +++++++++++ src/staged_pkg.rs | 25 +- 11 files changed, 1437 insertions(+), 170 deletions(-) create mode 100644 src/bin/repo_editor/app.rs create mode 100644 src/bin/repo_editor/jobs.rs create mode 100644 src/bin/repo_editor/main.rs create mode 100644 src/cook/tui.rs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f295c7032..e9e1a07bb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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: diff --git a/Cargo.toml b/Cargo.toml index 91e9cca2f..c4b6e2877 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/mk/fstools.mk b/mk/fstools.mk index c8b4246e6..04439fa7d 100644 --- a/mk/fstools.mk +++ b/mk/fstools.mk @@ -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) diff --git a/src/bin/repo.rs b/src/bin/repo.rs index 02eb8602f..972589c8f 100644 --- a/src/bin/repo.rs +++ b/src/bin/repo.rs @@ -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) -> Result<(CliConfig, CliCommand, Vec) -> Result<(CliConfig, CliCommand, Vec { // 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::>() + staged_pkg::list_repo(&config.repo_dir)? } }; @@ -906,6 +904,12 @@ fn handle_clean(recipe: &CookRecipe, config: &CliConfig, command: &CliCommand) - static PUSH_CONFIG: OnceLock = OnceLock::new(); fn handle_push(recipes: &Vec, 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) -> Result { 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 = 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) -> Result { 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) -> Result { 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) -> Result { Ok(app) } -fn join_logs(log: &Vec, line: Option>) -> 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, diff --git a/src/bin/repo_editor/app.rs b/src/bin/repo_editor/app.rs new file mode 100644 index 000000000..54d998227 --- /dev/null +++ b/src/bin/repo_editor/app.rs @@ -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 { + 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, + pub all_compiled_recipes: Vec, + pub all_config_recipes: Vec, + pub filtered_recipes: Vec, + pub pinned_recipes: BTreeSet, + pub filter_source: FilterSource, + pub list_state: ListState, + pub focus: Focus, + pub buttons: Vec, + + pub last_click_time: Option, + pub last_click_idx: Option, + + pub filter_tabs_area: Rect, + pub right_tabs_area: Rect, + pub right_tabs_str: Vec, + 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 { + 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 { + 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 { + 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) + } +} diff --git a/src/bin/repo_editor/jobs.rs b/src/bin/repo_editor/jobs.rs new file mode 100644 index 000000000..6af8fe8f6 --- /dev/null +++ b/src/bin/repo_editor/jobs.rs @@ -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), + JobFinished(JobId, i32), +} + +pub struct PackageJob { + pub id: JobId, + pub command: String, + pub targets: Vec, + pub logs: Vec, + pub buffer: Vec, + pub scroll: usize, + pub auto_scroll: bool, + pub exit_code: Option, + pub pid: Option, + pub pty_writer: Box, +} + +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, + pub active_job_order: Vec, +} + +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, + status_tx: mpsc::Sender, + ) { + 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(mut reader: R, job_id: JobId, status_tx: mpsc::Sender) +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; + } + } + } + }); +} diff --git a/src/bin/repo_editor/main.rs b/src/bin/repo_editor/main.rs new file mode 100644 index 000000000..f7890066d --- /dev/null +++ b/src/bin/repo_editor/main.rs @@ -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::(); + 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::(); + 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 = 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( + app: &mut App, + terminal: &mut Terminal, + event: Event, + status_tx: &mpsc::Sender, +) -> 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(()) +} diff --git a/src/cook.rs b/src/cook.rs index 9c989215c..d85e833aa 100644 --- a/src/cook.rs +++ b/src/cook.rs @@ -8,3 +8,4 @@ pub mod package; pub mod pty; pub mod script; pub mod tree; +pub mod tui; diff --git a/src/cook/pty.rs b/src/cook/pty.rs index 2e1ed2366..695a39ee6 100644 --- a/src/cook/pty.rs +++ b/src/cook/pty.rs @@ -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, 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, + PipeReader, + Box, + (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 Result { + 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 { + 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 { + fn spawn_command(&self, cmd: &mut Command, stdin: bool) -> Result { + 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 { - Ok(self.fd.spawn_command(builder)?) + Ok(self.fd.spawn_command(builder, false)?) + } + fn spawn_command_stdin(&self, builder: &mut Command) -> Result { + 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> { + let fd = PtyFd( + self.fd + .try_clone() + .map_err(wrap_io_err!("Cloning pty fd"))?, + ); + Ok(Box::new(fd)) + } } diff --git a/src/cook/tui.rs b/src/cook/tui.rs new file mode 100644 index 000000000..013f0948f --- /dev/null +++ b/src/cook/tui.rs @@ -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, lines: &mut Vec) -> 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>, + log_line: Option>, + may_autoscroll: bool, + enable_auto_scroll: &'a mut bool, + intended_scroll_pos: &'a mut usize, + search_result: Option<(usize, &'a Vec)>, +) -> Vec> { + let mut log_lines: Vec = 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, line: Option>) -> 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, + divider_len: u16, +) -> Option { + 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) { + 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"); +} diff --git a/src/staged_pkg.rs b/src/staged_pkg.rs index 7248915c0..3e02f37a4 100644 --- a/src/staged_pkg.rs +++ b/src/staged_pkg.rs @@ -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) -> BTreeSet { - let prefix = prefix.as_ref(); - RECIPE_PATHS - .values() - .map(|path| prefix.join(path)) - .collect() +pub fn list() -> BTreeSet { + RECIPE_PATHS.values().map(|p| p.to_path_buf()).collect() +} + +pub fn list_repo(repo_dir: impl AsRef) -> crate::Result> { + 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::>()) } pub fn new(name: &PackageName) -> Result {