use std::fs;
use std::{collections::HashSet, process::Stdio};
use color_eyre::Result;
use ratatui::{
DefaultTerminal, Frame,
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::{Color, Style, Stylize},
text::Line,
widgets::{Block, List, ListItem, Paragraph, Widget},
};
use crate::config::Config;
use crate::merge_request;
mod input;
#[derive(Debug, Default)]
pub(crate) enum Screens {
#[default]
RepoSelection,
CreateMR,
ReviewerSelection,
Finalize,
}
impl Screens {
pub(crate) fn help(&self) -> &'static str {
match self {
Screens::RepoSelection => "↑/↓/j/k: Move Space: Select Enter: Next q/Esc: Quit",
Screens::CreateMR => "Tab: Switch field ↑/↓/j/k: Select Label Enter: Next Esc: Back",
Screens::ReviewerSelection => "↑/↓/j/k: Move Space: Select Enter: Next Esc: Back",
Screens::Finalize => "y/Enter: Confirm n/Esc: Back",
}
}
pub(crate) fn title(&self) -> &'static str {
match self {
Screens::RepoSelection => "Select Repos",
Screens::CreateMR => "Describe",
Screens::ReviewerSelection => "Add Reviewers",
Screens::Finalize => "Finalize",
}
}
}
#[derive(Debug, Default)]
pub struct App {
pub(crate) config: Config,
pub(crate) running: bool,
pub(crate) dirs: Vec<String>,
pub(crate) branches: Vec<String>,
pub(crate) selected_repos: HashSet<usize>,
pub(crate) selected_index: usize,
pub(crate) screen: Screens,
pub(crate) mr_title: String,
pub(crate) mr_description: String,
pub(crate) selected_reviewers: HashSet<usize>,
pub(crate) selected_label: usize,
pub(crate) user_input_completed: bool,
pub(crate) input_focus: InputFocus,
pub(crate) reviewer_index: usize,
pub(crate) mr: Option<merge_request::MergeRequest>,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) enum InputFocus {
#[default]
Title,
Description,
Label,
}
impl App {
pub(crate) fn new(config: Config) -> Self {
let mut app = Self {
config,
selected_label: 0,
selected_index: 0,
..Default::default()
};
if let Ok(entries) = fs::read_dir(&app.config.working_dir) {
app.dirs = entries
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let path = entry.path();
if path.is_dir() {
path.file_name().map(|n| n.to_string_lossy().to_string())
} else {
None
}
})
.collect();
let mut valid_dirs = Vec::new();
for dir in &app.dirs {
if std::process::Command::new("git")
.arg("rev-parse")
.arg("--is-inside-work-tree")
.current_dir(app.config.working_dir.join(dir))
.stderr(Stdio::null())
.stdout(Stdio::null())
.status()
.is_ok()
{
valid_dirs.push(dir.clone());
}
}
app.dirs = valid_dirs;
for dir in app.dirs.iter() {
if let Ok(current_branch_output) = std::process::Command::new("git")
.arg("branch")
.arg("--show-current")
.current_dir(app.config.working_dir.join(dir))
.output()
{
app.branches.push(
String::from_utf8_lossy(¤t_branch_output.stdout)
.trim()
.to_string(),
)
}
}
}
app
}
pub(crate) fn run(mut self, mut terminal: DefaultTerminal) -> Result<Self> {
self.running = true;
while self.running {
terminal.draw(|frame| self.render(frame))?;
self.handle_crossterm_events()?;
}
Ok(self)
}
pub(crate) fn render(&mut self, frame: &mut Frame) {
let [window, footer] = Layout::vertical([
Constraint::Min(0), Constraint::Length(1), ])
.areas(frame.area());
let title = Line::from(format!("Multi MR - {}", self.screen.title()))
.bold()
.blue()
.centered();
let outer_block = Block::bordered().title(title);
let inner_area = outer_block.inner(window);
match self.screen {
Screens::RepoSelection => self.render_repo_selection(inner_area, frame.buffer_mut()),
Screens::CreateMR => self.render_create_mr(inner_area, frame.buffer_mut()),
Screens::ReviewerSelection => {
self.render_reviewer_selection(inner_area, frame.buffer_mut())
}
Screens::Finalize => self.render_overview(inner_area, frame.buffer_mut()),
}
outer_block.render(window, frame.buffer_mut());
Paragraph::new(self.screen.help())
.centered()
.style(Style::default().fg(Color::DarkGray))
.render(footer, frame.buffer_mut());
}
pub(crate) fn render_repo_selection(&mut self, window: Rect, buf: &mut Buffer) {
let [repo_list_area, dir_info_area] = Layout::vertical([
Constraint::Min(3),
Constraint::Length(1), ])
.areas(window);
let repos: Vec<ListItem> = self
.dirs
.iter()
.enumerate()
.map(|(i, d)| {
let line = if self.selected_repos.contains(&i) {
format!(
"[x] {} ({})",
d,
self.branches.get(i).unwrap_or(&"???".to_string())
)
} else {
format!(
"[ ] {} ({})",
d,
self.branches.get(i).unwrap_or(&"???".to_string())
)
};
let mut item = ListItem::new(line);
if i == self.selected_index {
item = item.style(Style::default().fg(Color::Yellow).bg(Color::Blue));
}
item
})
.collect();
List::new(repos).render(repo_list_area, buf);
Paragraph::new(format!(
"Current directory: {} (Selected: {})",
self.config.working_dir.display(),
self.selected_repos.len()
))
.centered()
.render(dir_info_area, buf);
}
pub(crate) fn render_create_mr(&mut self, window: Rect, buf: &mut Buffer) {
let [
dir_area,
title_input_area,
description_input_area,
label_input_area,
] = Layout::vertical([
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(5),
])
.areas(window);
let selected_dirs: Vec<&String> = self
.selected_repos
.iter()
.copied()
.filter_map(|i| self.dirs.get(i))
.collect();
let dirs_text = if selected_dirs.is_empty() {
"No repositories selected".to_string()
} else {
selected_dirs
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join("\n")
};
Paragraph::new(format!("Repositories:\n{}", dirs_text)).render(dir_area, buf);
Paragraph::new(self.mr_title.as_str())
.style(if self.input_focus == InputFocus::Title {
Style::default().bg(Color::Blue).fg(Color::White)
} else {
Style::default()
})
.block(Block::bordered().title("Title"))
.render(title_input_area, buf);
Paragraph::new(self.mr_description.as_str())
.style(if self.input_focus == InputFocus::Description {
Style::default().bg(Color::Blue).fg(Color::White)
} else {
Style::default()
})
.block(Block::bordered().title("Description"))
.render(description_input_area, buf);
let label_items: Vec<ListItem> = self
.config
.labels
.iter()
.enumerate()
.map(|(i, (k, v))| {
let marker = if i == self.selected_label {
"(x)"
} else {
"( )"
};
let mut item = ListItem::new(format!("{} {}: {}", marker, k, v));
if self.input_focus == InputFocus::Label && i == self.selected_label {
item = item.style(Style::default().fg(Color::Yellow).bg(Color::Blue));
} else if i == self.selected_label {
item = item.style(Style::default().fg(Color::Yellow));
}
item
})
.collect();
List::new(label_items)
.block(Block::bordered().title("Gitlab Label"))
.render(label_input_area, buf);
}
pub(crate) fn render_reviewer_selection(&mut self, window: Rect, buf: &mut Buffer) {
let [reviewer_area, assignee_area] =
Layout::vertical([Constraint::Min(1), Constraint::Min(1)]).areas(window);
let items: Vec<ListItem> = self
.config
.reviewers
.iter()
.enumerate()
.map(|(i, r)| {
let line = if self.selected_reviewers.contains(&i) {
format!("[x] {}", r)
} else {
format!("[ ] {}", r)
};
let mut item = ListItem::new(line);
if i == self.reviewer_index {
item = item.style(Style::default().fg(Color::Yellow).bg(Color::Blue));
}
item
})
.collect();
List::new(items).render(reviewer_area, buf);
if let Some(assignee) = &self.config.assignee {
Paragraph::new(format!("Assignee: {}", assignee))
.style(Style::default().fg(Color::Green))
.render(assignee_area, buf);
} else {
Paragraph::new("No assignee set")
.style(Style::default().fg(Color::Red))
.render(assignee_area, buf);
}
}
pub(crate) fn render_overview(&mut self, window: Rect, buf: &mut Buffer) {
let selected_dirs: Vec<&String> = self
.selected_repos
.iter()
.copied()
.filter_map(|i| self.dirs.get(i))
.collect();
let selected_reviewers: Vec<&String> = self
.selected_reviewers
.iter()
.copied()
.filter_map(|i| self.config.reviewers.get(i))
.collect();
let dirs_text = if selected_dirs.is_empty() {
"No repositories selected".to_string()
} else {
selected_dirs
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
};
let reviewers_text = if selected_reviewers.is_empty() {
"No reviewers selected".to_string()
} else {
selected_reviewers
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
};
let [overview_area] = Layout::vertical([Constraint::Min(1)]).areas(window);
Paragraph::new(format!(
"Overview\n\nRepositories: {}\nTitle: {}\nDescription: {}\nReviewers: {}\n\nPress 'y' to confirm, 'n' to go back.",
dirs_text, self.mr_title, self.mr_description, reviewers_text
)).render(overview_area, buf);
}
pub(crate) fn quit(&mut self) {
self.running = false;
}
pub(crate) fn quit_completed(&mut self) {
self.user_input_completed = true;
self.running = false;
}
}