345 lines
9.8 KiB
Rust
345 lines
9.8 KiB
Rust
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
|
|
use gotcha_gitea::{Provider, RepositoryId};
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum EditorAction {
|
|
Issue {
|
|
repository: RepositoryId,
|
|
number: Option<i64>,
|
|
},
|
|
Comment {
|
|
repository: RepositoryId,
|
|
number: i64,
|
|
comment_id: Option<i64>,
|
|
},
|
|
Milestone {
|
|
repository: RepositoryId,
|
|
id: Option<i64>,
|
|
},
|
|
Server {
|
|
original_name: Option<String>,
|
|
},
|
|
Settings,
|
|
IssueFilter,
|
|
PullFilter,
|
|
ActionDispatch {
|
|
repository: RepositoryId,
|
|
workflow: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct Field {
|
|
pub label: String,
|
|
pub value: String,
|
|
pub secret: bool,
|
|
pub multiline: bool,
|
|
}
|
|
|
|
impl Field {
|
|
pub fn new(label: &str, value: impl Into<String>) -> Self {
|
|
Self {
|
|
label: label.into(),
|
|
value: value.into(),
|
|
secret: false,
|
|
multiline: false,
|
|
}
|
|
}
|
|
|
|
pub fn secret(mut self) -> Self {
|
|
self.secret = true;
|
|
self
|
|
}
|
|
|
|
pub fn multiline(mut self) -> Self {
|
|
self.multiline = true;
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Editor {
|
|
pub title: String,
|
|
pub fields: Vec<Field>,
|
|
pub focus: usize,
|
|
pub cursor: usize,
|
|
pub action: EditorAction,
|
|
}
|
|
|
|
impl Editor {
|
|
pub fn issue(repository: RepositoryId, number: Option<i64>, fields: [String; 6]) -> Self {
|
|
let [title, body, labels, milestone, due_date, state] = fields;
|
|
Self::new(
|
|
if number.is_some() {
|
|
"Edit issue"
|
|
} else {
|
|
"New issue"
|
|
},
|
|
EditorAction::Issue { repository, number },
|
|
vec![
|
|
Field::new("Title", title),
|
|
Field::new("Body", body).multiline(),
|
|
Field::new("Labels (comma separated)", labels),
|
|
Field::new("Milestone", milestone),
|
|
Field::new("Due date (YYYY-MM-DD)", due_date),
|
|
Field::new("State (open/closed)", state),
|
|
],
|
|
)
|
|
}
|
|
|
|
pub fn comment(
|
|
repository: RepositoryId,
|
|
number: i64,
|
|
comment_id: Option<i64>,
|
|
body: String,
|
|
) -> Self {
|
|
Self::new(
|
|
if comment_id.is_some() {
|
|
"Edit comment"
|
|
} else {
|
|
"New comment"
|
|
},
|
|
EditorAction::Comment {
|
|
repository,
|
|
number,
|
|
comment_id,
|
|
},
|
|
vec![Field::new("Comment", body).multiline()],
|
|
)
|
|
}
|
|
|
|
pub fn issue_filter(state: &str, labels: &str, milestone: &str, search: &str) -> Self {
|
|
Self::new(
|
|
"Issue filters",
|
|
EditorAction::IssueFilter,
|
|
vec![
|
|
Field::new("State (open/closed/all)", state),
|
|
Field::new("Labels (comma separated)", labels),
|
|
Field::new("Milestone", milestone),
|
|
Field::new("Search", search),
|
|
],
|
|
)
|
|
}
|
|
|
|
pub fn pull_filter(state: &str, milestone: &str, search: &str) -> Self {
|
|
Self::new(
|
|
"Pull request filters",
|
|
EditorAction::PullFilter,
|
|
vec![
|
|
Field::new("State (open/closed)", state),
|
|
Field::new("Milestone", milestone),
|
|
Field::new("Search", search),
|
|
],
|
|
)
|
|
}
|
|
|
|
pub fn milestone(
|
|
repository: RepositoryId,
|
|
id: Option<i64>,
|
|
title: String,
|
|
description: String,
|
|
due_date: String,
|
|
state: String,
|
|
) -> Self {
|
|
Self::new(
|
|
if id.is_some() {
|
|
"Edit milestone"
|
|
} else {
|
|
"New milestone"
|
|
},
|
|
EditorAction::Milestone { repository, id },
|
|
vec![
|
|
Field::new("Title", title),
|
|
Field::new("Description", description).multiline(),
|
|
Field::new("Due date (YYYY-MM-DD)", due_date),
|
|
Field::new("State (open/closed)", state),
|
|
],
|
|
)
|
|
}
|
|
|
|
pub fn server(
|
|
original_name: Option<String>,
|
|
name: String,
|
|
url: String,
|
|
provider: Provider,
|
|
) -> Self {
|
|
Self::new(
|
|
if original_name.is_some() {
|
|
"Edit server"
|
|
} else {
|
|
"Add server"
|
|
},
|
|
EditorAction::Server { original_name },
|
|
vec![
|
|
Field::new("Profile name (host[:port])", name),
|
|
Field::new("Server URL", url),
|
|
Field::new("Token (blank keeps existing)", "").secret(),
|
|
Field::new("Provider (gitea/forgejo)", provider.to_string()),
|
|
],
|
|
)
|
|
}
|
|
|
|
pub fn settings(refresh_seconds: u64) -> Self {
|
|
Self::new(
|
|
"TUI preferences",
|
|
EditorAction::Settings,
|
|
vec![Field::new(
|
|
"Auto-reload seconds (0 disables)",
|
|
refresh_seconds.to_string(),
|
|
)],
|
|
)
|
|
}
|
|
|
|
pub fn action_dispatch(repository: RepositoryId, workflow: String, reference: String) -> Self {
|
|
Self::new(
|
|
"Dispatch workflow",
|
|
EditorAction::ActionDispatch {
|
|
repository,
|
|
workflow,
|
|
},
|
|
vec![
|
|
Field::new("Git reference", reference),
|
|
Field::new("Inputs (one KEY=VALUE per line)", "").multiline(),
|
|
],
|
|
)
|
|
}
|
|
|
|
fn new(title: &str, action: EditorAction, fields: Vec<Field>) -> Self {
|
|
let cursor = fields
|
|
.first()
|
|
.map_or(0, |field| field.value.chars().count());
|
|
Self {
|
|
title: title.into(),
|
|
fields,
|
|
focus: 0,
|
|
cursor,
|
|
action,
|
|
}
|
|
}
|
|
|
|
pub fn handle_key(&mut self, key: KeyEvent) -> EditorEvent {
|
|
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
|
|
return EditorEvent::Submit;
|
|
}
|
|
match key.code {
|
|
KeyCode::Esc => EditorEvent::Cancel,
|
|
KeyCode::Tab | KeyCode::Down => {
|
|
self.move_focus(1);
|
|
EditorEvent::Changed
|
|
}
|
|
KeyCode::BackTab | KeyCode::Up => {
|
|
self.move_focus(self.fields.len().saturating_sub(1));
|
|
EditorEvent::Changed
|
|
}
|
|
KeyCode::Left => {
|
|
self.cursor = self.cursor.saturating_sub(1);
|
|
EditorEvent::Changed
|
|
}
|
|
KeyCode::Right => {
|
|
self.cursor = (self.cursor + 1).min(self.current_len());
|
|
EditorEvent::Changed
|
|
}
|
|
KeyCode::Home => {
|
|
self.cursor = 0;
|
|
EditorEvent::Changed
|
|
}
|
|
KeyCode::End => {
|
|
self.cursor = self.current_len();
|
|
EditorEvent::Changed
|
|
}
|
|
KeyCode::Backspace => {
|
|
if self.cursor > 0 {
|
|
let end = byte_index(&self.fields[self.focus].value, self.cursor);
|
|
let start = byte_index(&self.fields[self.focus].value, self.cursor - 1);
|
|
self.fields[self.focus].value.replace_range(start..end, "");
|
|
self.cursor -= 1;
|
|
}
|
|
EditorEvent::Changed
|
|
}
|
|
KeyCode::Delete => {
|
|
if self.cursor < self.current_len() {
|
|
let start = byte_index(&self.fields[self.focus].value, self.cursor);
|
|
let end = byte_index(&self.fields[self.focus].value, self.cursor + 1);
|
|
self.fields[self.focus].value.replace_range(start..end, "");
|
|
}
|
|
EditorEvent::Changed
|
|
}
|
|
KeyCode::Enter if self.fields[self.focus].multiline => {
|
|
self.insert('\n');
|
|
EditorEvent::Changed
|
|
}
|
|
KeyCode::Enter => {
|
|
self.move_focus(1);
|
|
EditorEvent::Changed
|
|
}
|
|
KeyCode::Char(character)
|
|
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
|
|
{
|
|
self.insert(character);
|
|
EditorEvent::Changed
|
|
}
|
|
_ => EditorEvent::Ignored,
|
|
}
|
|
}
|
|
|
|
fn insert(&mut self, character: char) {
|
|
let index = byte_index(&self.fields[self.focus].value, self.cursor);
|
|
self.fields[self.focus].value.insert(index, character);
|
|
self.cursor += 1;
|
|
}
|
|
|
|
fn move_focus(&mut self, amount: usize) {
|
|
self.focus = (self.focus + amount) % self.fields.len();
|
|
self.cursor = self.current_len();
|
|
}
|
|
|
|
fn current_len(&self) -> usize {
|
|
self.fields[self.focus].value.chars().count()
|
|
}
|
|
|
|
pub fn values(&self) -> Vec<String> {
|
|
self.fields
|
|
.iter()
|
|
.map(|field| field.value.clone())
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum EditorEvent {
|
|
Changed,
|
|
Submit,
|
|
Cancel,
|
|
Ignored,
|
|
}
|
|
|
|
fn byte_index(value: &str, character: usize) -> usize {
|
|
value
|
|
.char_indices()
|
|
.nth(character)
|
|
.map_or(value.len(), |(index, _)| index)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn editor_backspace_edits_instead_of_navigating() {
|
|
let mut editor = Editor::settings(5);
|
|
editor.fields[0].value = "é5".into();
|
|
editor.cursor = 1;
|
|
assert_eq!(
|
|
editor.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
|
|
EditorEvent::Changed
|
|
);
|
|
assert_eq!(editor.fields[0].value, "5");
|
|
assert_eq!(editor.cursor, 0);
|
|
assert_eq!(
|
|
editor.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
|
|
EditorEvent::Changed
|
|
);
|
|
}
|
|
}
|