chlog: add series selector
This commit is contained in:
@@ -7,6 +7,7 @@ authors = ["vhaudiquet"]
|
||||
[dependencies]
|
||||
clap = { version = "4.5.51", features = ["cargo"] }
|
||||
cmd_lib = "2.0.0"
|
||||
crossterm = "0.28"
|
||||
flate2 = "1.1.5"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
libc = "0.2"
|
||||
|
||||
+3
-1
@@ -10,6 +10,7 @@ pub fn generate_entry(
|
||||
changelog_file: &str,
|
||||
cwd: Option<&Path>,
|
||||
user_version: Option<&str>,
|
||||
target_series: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let changelog_path = if let Some(path) = cwd {
|
||||
path.join(changelog_file)
|
||||
@@ -43,6 +44,7 @@ pub fn generate_entry(
|
||||
};
|
||||
|
||||
let (maintainer_name, maintainer_email) = get_maintainer_info()?;
|
||||
let series = target_series.unwrap_or(&series).to_string();
|
||||
let new_entry = format_entry(
|
||||
&package,
|
||||
&new_version,
|
||||
@@ -426,7 +428,7 @@ mod tests {
|
||||
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
|
||||
std::env::set_var("DEBEMAIL", "maintainer@maintainer.com");
|
||||
}
|
||||
generate_entry("debian/changelog", Some(repo_dir), None).unwrap();
|
||||
generate_entry("debian/changelog", Some(repo_dir), None, None).unwrap();
|
||||
unsafe {
|
||||
std::env::remove_var("DEBFULLNAME");
|
||||
std::env::remove_var("DEBEMAIL");
|
||||
|
||||
+43
-1
@@ -150,8 +150,50 @@ fn main() {
|
||||
Some(("chlog", sub_matches)) => {
|
||||
let cwd = current_dir_or_exit();
|
||||
let version = sub_matches.get_one::<String>("version").map(|s| s.as_str());
|
||||
let cli_series = sub_matches.get_one::<String>("series").map(|s| s.as_str());
|
||||
|
||||
if let Err(e) = generate_entry("debian/changelog", Some(&cwd), version) {
|
||||
// Determine target series: CLI flag > interactive selector > current changelog series
|
||||
let target_series = if let Some(s) = cli_series {
|
||||
Some(s.to_string())
|
||||
} else {
|
||||
// Parse current changelog to determine the default series
|
||||
let changelog_path = cwd.join("debian/changelog");
|
||||
match pkh::changelog::parse_changelog_header(&changelog_path) {
|
||||
Ok((_pkg, _ver, current_series)) => {
|
||||
// Try to get the list of available series for this distribution
|
||||
match rt.block_on(async {
|
||||
let dist =
|
||||
pkh::distro_info::get_dist_from_series(¤t_series).await?;
|
||||
pkh::distro_info::get_ordered_series_name(&dist).await
|
||||
}) {
|
||||
Ok(series_list) => {
|
||||
match ui::select_series(&series_list, ¤t_series) {
|
||||
Ok(selected) => Some(selected),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Series selection failed: {}. Using current series '{}' instead.",
|
||||
e, current_series
|
||||
);
|
||||
Some(current_series)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Could not fetch series list, use current series as default
|
||||
Some(current_series)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = generate_entry(
|
||||
"debian/changelog",
|
||||
Some(&cwd),
|
||||
version,
|
||||
target_series.as_deref(),
|
||||
) {
|
||||
error!("{}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
use crossterm::{
|
||||
cursor, event, execute,
|
||||
style::{self, Color, Print, SetForegroundColor},
|
||||
terminal,
|
||||
};
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||
use std::io::{self, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn create_progress_bar(
|
||||
@@ -39,3 +45,177 @@ pub fn create_progress_bar(
|
||||
|
||||
(pb, callback)
|
||||
}
|
||||
|
||||
const SELECT_PROMPT: &str = "> Select series: ";
|
||||
|
||||
/// Interactive one-line series selector with arrow key navigation and direct typing.
|
||||
///
|
||||
/// The user can:
|
||||
/// - Navigate with ↑/↓ arrow keys to cycle through options
|
||||
/// - Type directly to enter a custom release name
|
||||
/// - Press Tab to cycle through options matching the current input prefix
|
||||
/// - Press Enter to confirm, Escape to use the default
|
||||
///
|
||||
/// Returns the selected series name, or an error if cancelled (Ctrl+C).
|
||||
pub fn select_series(
|
||||
options: &[String],
|
||||
default: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
if options.is_empty() {
|
||||
println!("{}{} (default)", SELECT_PROMPT, default);
|
||||
return Ok(default.to_string());
|
||||
}
|
||||
|
||||
// Try to enter raw mode — if not possible (e.g. piped input), fall back to default
|
||||
if terminal::enable_raw_mode().is_err() {
|
||||
println!("{}{} (default)", SELECT_PROMPT, default);
|
||||
return Ok(default.to_string());
|
||||
}
|
||||
|
||||
let result = select_series_inner(options, default);
|
||||
|
||||
// Always restore the terminal
|
||||
let _ = terminal::disable_raw_mode();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn select_series_inner(
|
||||
options: &[String],
|
||||
default: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let default_idx = options.iter().position(|o| o == default).unwrap_or(0);
|
||||
let mut selected_idx = default_idx;
|
||||
let mut input = default.to_string();
|
||||
// Whether we are in "navigation mode" (last action was arrow/tab selecting an option)
|
||||
// vs "typing mode" (last action was typing a character)
|
||||
let mut navigating = true;
|
||||
|
||||
let prompt_len = SELECT_PROMPT.len();
|
||||
|
||||
let mut stdout = io::stdout();
|
||||
|
||||
// Draw the prompt line, clearing any previous content
|
||||
let draw = |stdout: &mut io::Stdout, input: &str| -> Result<(), Box<dyn std::error::Error>> {
|
||||
execute!(
|
||||
stdout,
|
||||
cursor::MoveToColumn(0),
|
||||
Print(SELECT_PROMPT),
|
||||
SetForegroundColor(Color::Cyan),
|
||||
Print(input),
|
||||
style::ResetColor,
|
||||
terminal::Clear(terminal::ClearType::UntilNewLine),
|
||||
)?;
|
||||
stdout.flush()?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
draw(&mut stdout, &input)?;
|
||||
|
||||
loop {
|
||||
if event::poll(Duration::from_millis(200))?
|
||||
&& let event::Event::Key(event::KeyEvent {
|
||||
code, modifiers, ..
|
||||
}) = event::read()?
|
||||
{
|
||||
match code {
|
||||
event::KeyCode::Up => {
|
||||
navigating = true;
|
||||
if selected_idx > 0 {
|
||||
selected_idx -= 1;
|
||||
} else {
|
||||
selected_idx = options.len() - 1;
|
||||
}
|
||||
input = options[selected_idx].clone();
|
||||
draw(&mut stdout, &input)?;
|
||||
}
|
||||
event::KeyCode::Down => {
|
||||
navigating = true;
|
||||
if selected_idx < options.len() - 1 {
|
||||
selected_idx += 1;
|
||||
} else {
|
||||
selected_idx = 0;
|
||||
}
|
||||
input = options[selected_idx].clone();
|
||||
draw(&mut stdout, &input)?;
|
||||
}
|
||||
event::KeyCode::Enter => {
|
||||
break;
|
||||
}
|
||||
event::KeyCode::Esc => {
|
||||
input = default.to_string();
|
||||
break;
|
||||
}
|
||||
event::KeyCode::Char(c) => {
|
||||
if modifiers.contains(event::KeyModifiers::CONTROL) && c == 'c' {
|
||||
// Move to a new line before returning so the terminal isn't messed up
|
||||
execute!(stdout, Print("\r\n"))?;
|
||||
return Err("Cancelled".into());
|
||||
}
|
||||
navigating = false;
|
||||
input.push(c);
|
||||
// Auto-select if the input exactly matches an option
|
||||
if let Some(idx) = options.iter().position(|o| o == &input) {
|
||||
selected_idx = idx;
|
||||
navigating = true;
|
||||
}
|
||||
draw(&mut stdout, &input)?;
|
||||
}
|
||||
event::KeyCode::Backspace => {
|
||||
if !input.is_empty() {
|
||||
input.pop();
|
||||
navigating = false;
|
||||
if let Some(idx) = options.iter().position(|o| o == &input) {
|
||||
selected_idx = idx;
|
||||
navigating = true;
|
||||
}
|
||||
// Need to redraw — move cursor to end of prompt + input
|
||||
execute!(
|
||||
stdout,
|
||||
cursor::MoveToColumn(prompt_len as u16),
|
||||
Print(&input),
|
||||
terminal::Clear(terminal::ClearType::UntilNewLine),
|
||||
)?;
|
||||
stdout.flush()?;
|
||||
}
|
||||
}
|
||||
event::KeyCode::Tab => {
|
||||
// Cycle through options that start with the current input
|
||||
let matches: Vec<usize> = options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, o)| o.starts_with(&input))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if !matches.is_empty() {
|
||||
let next = if navigating {
|
||||
matches
|
||||
.iter()
|
||||
.find(|&&i| i > selected_idx)
|
||||
.or_else(|| matches.first())
|
||||
} else {
|
||||
matches.first()
|
||||
};
|
||||
if let Some(&idx) = next {
|
||||
selected_idx = idx;
|
||||
input = options[idx].clone();
|
||||
navigating = true;
|
||||
}
|
||||
draw(&mut stdout, &input)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move to a new line so subsequent output doesn't overwrite the prompt
|
||||
execute!(
|
||||
stdout,
|
||||
terminal::Clear(terminal::ClearType::UntilNewLine),
|
||||
Print("\r\n"),
|
||||
)?;
|
||||
stdout.flush()?;
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user