use crate::commands::{all_commands, Category, CommandDef};
use std::{
io,
process::{Command, Stdio},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
Menu,
Prompt,
Output,
Confirm,
Help,
}
#[derive(Clone, Debug)]
pub enum Action {
Help,
Run {
needs_input: bool,
needs_confirm: bool,
},
}
pub struct App {
pub cli: String,
pub commands: Vec<CommandDef>,
pub category_idx: usize,
pub selected: usize,
pub mode: Mode,
pub input: String,
pub prompt_hint: String,
pub prompt_required: bool,
pub output: String,
pub output_scroll: usize,
pub status: String,
pub should_quit: bool,
pub confirm_yes: bool,
pending_interactive: Option<Vec<String>>,
pending_args: Option<Vec<String>>,
}
impl App {
pub fn new(cli: String) -> Self {
Self {
cli,
commands: all_commands(),
category_idx: 0,
selected: 0,
mode: Mode::Menu,
input: String::new(),
prompt_hint: String::new(),
prompt_required: false,
output: String::new(),
output_scroll: 0,
status: "↑↓ navigate · ←→ category · ⏎ run · ? help · q quit".into(),
should_quit: false,
confirm_yes: false,
pending_interactive: None,
pending_args: None,
}
}
pub fn categories(&self) -> &'static [Category] {
&Category::ALL
}
pub fn current_category(&self) -> Category {
Category::ALL[self.category_idx]
}
pub fn current_items(&self) -> Vec<&CommandDef> {
let cat = self.current_category();
self.commands
.iter()
.filter(|c| c.category == cat)
.collect()
}
pub fn selected_cmd(&self) -> Option<&CommandDef> {
self.current_items().get(self.selected).copied()
}
pub fn selected_action(&self) -> Action {
match self.selected_cmd() {
Some(cmd) => Action::Run {
needs_input: cmd.needs_input,
needs_confirm: cmd.needs_confirm,
},
None => Action::Help,
}
}
pub fn next(&mut self) {
let len = self.current_items().len();
if len == 0 {
return;
}
self.selected = (self.selected + 1) % len;
}
pub fn prev(&mut self) {
let len = self.current_items().len();
if len == 0 {
return;
}
self.selected = if self.selected == 0 {
len - 1
} else {
self.selected - 1
};
}
pub fn select_first(&mut self) {
self.selected = 0;
}
pub fn select_last(&mut self) {
let len = self.current_items().len();
if len > 0 {
self.selected = len - 1;
}
}
pub fn next_category(&mut self) {
self.category_idx = (self.category_idx + 1) % Category::ALL.len();
self.selected = 0;
}
pub fn prev_category(&mut self) {
self.category_idx = if self.category_idx == 0 {
Category::ALL.len() - 1
} else {
self.category_idx - 1
};
self.selected = 0;
}
pub fn begin_prompt(&mut self) {
let Some((hint, required, name)) = self.selected_cmd().map(|c| {
(c.input_hint, c.input_required, c.name)
}) else {
return;
};
self.prompt_hint = hint.into();
self.prompt_required = required;
self.input.clear();
self.mode = Mode::Prompt;
self.status = format!("enter args for `{name}` · Esc cancel");
}
pub fn submit_prompt(&mut self) -> io::Result<()> {
let mut args = self.base_args();
for token in shell_split(&self.input) {
args.push(token);
}
self.input.clear();
self.execute(args)
}
pub fn run_selected(&mut self) -> io::Result<()> {
let Some(cmd) = self.selected_cmd().cloned() else {
return Ok(());
};
if cmd.needs_confirm && self.mode == Mode::Confirm && !self.confirm_yes {
return Ok(());
}
self.confirm_yes = false;
if cmd.needs_confirm && self.mode == Mode::Confirm {
if cmd.needs_input {
self.begin_prompt();
return Ok(());
}
}
if cmd.interactive {
let mut args: Vec<String> = cmd.args.iter().map(|s| (*s).to_string()).collect();
if !self.input.is_empty() {
for token in shell_split(&self.input) {
args.push(token);
}
self.input.clear();
}
if cmd.needs_input && args.len() == cmd.args.len() {
self.begin_prompt();
return Ok(());
}
self.pending_interactive = Some(args);
self.should_quit = true;
self.status = format!("launching interactive `{}`…", cmd.name);
return Ok(());
}
let args = self.base_args();
self.execute(args)
}
fn base_args(&self) -> Vec<String> {
if let Some(pending) = &self.pending_args {
return pending.clone();
}
self.selected_cmd()
.map(|c| c.args.iter().map(|s| (*s).to_string()).collect())
.unwrap_or_default()
}
fn execute(&mut self, args: Vec<String>) -> io::Result<()> {
self.pending_args = None;
let label = args
.first()
.map(|s| s.as_str())
.unwrap_or("?")
.to_string();
self.status = format!("running `{} {}`…", self.cli, args.join(" "));
let is_interactive = self
.selected_cmd()
.map(|c| c.interactive)
.unwrap_or(false);
if is_interactive {
self.pending_interactive = Some(args);
self.should_quit = true;
return Ok(());
}
let output = Command::new(&self.cli)
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output();
match output {
Ok(out) => {
let mut buf = String::new();
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
if !stdout.is_empty() {
buf.push_str(&stdout);
}
if !stderr.is_empty() {
if !buf.is_empty() && !buf.ends_with('\n') {
buf.push('\n');
}
buf.push_str(&stderr);
}
if buf.is_empty() {
buf = if out.status.success() {
"(no output — success)".into()
} else {
format!("(no output — exit {})", out.status)
};
}
self.output = buf;
self.output_scroll = 0;
self.mode = Mode::Output;
self.status = if out.status.success() {
format!("✓ {label} finished")
} else {
format!("✗ {label} failed ({})", out.status)
};
}
Err(e) => {
self.output = format!(
"Failed to execute `{}`.\n\n{e}\n\n\
Tip: install fp-dotfiles-manager on PATH, or set:\n \
export FP_DOTFILES_MANAGER=/path/to/fp-dotfiles-manager",
self.cli
);
self.output_scroll = 0;
self.mode = Mode::Output;
self.status = "✗ could not spawn CLI".into();
}
}
Ok(())
}
pub fn scroll_output(&mut self, delta: isize) {
let lines = self.output.lines().count();
let max = lines.saturating_sub(1);
if delta < 0 {
self.output_scroll = self.output_scroll.saturating_sub((-delta) as usize);
} else {
self.output_scroll = (self.output_scroll + delta as usize).min(max);
}
}
pub fn take_pending_interactive(&mut self) -> Option<Vec<String>> {
self.pending_interactive.take()
}
}
fn shell_split(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut chars = s.chars().peekable();
let mut quote: Option<char> = None;
while let Some(c) = chars.next() {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some(_), c) => cur.push(c),
(None, '\'' | '"') => quote = Some(c),
(None, c) if c.is_whitespace() => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
(None, c) => cur.push(c),
}
}
if !quote.is_none() {
}
if !cur.is_empty() {
out.push(cur);
}
out
}
#[cfg(test)]
mod tests {
use super::shell_split;
#[test]
fn splits_simple() {
assert_eq!(shell_split("a b c"), vec!["a", "b", "c"]);
}
#[test]
fn splits_quoted() {
assert_eq!(
shell_split(r#"add "~/.config/my app" foo"#),
vec!["add", "~/.config/my app", "foo"]
);
}
}