use crate::route::{Route, issues, pulls};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui_textarea::TextArea;
use crate::api::browse::{IssueDetail, PrActivity};
use crate::api::mention::Mentionable;
use crate::api::model::{PrRef, RepoId};
use crate::data::DataKey;
use crate::diff_screen::DiffOf;
use crate::picker;
use crate::state::{Cmd, Overlay, Screen, State};
pub const SHOWN: usize = 6;
#[must_use]
pub fn on_paste(state: &mut State, text: &str) -> Vec<Cmd> {
let text = text.replace("\r\n", "\n").replace('\r', "\n");
let line = || {
let lines: Vec<&str> = text.split('\n').filter(|l| !l.trim().is_empty()).collect();
lines.join(" ")
};
match &mut state.overlay {
Some(Overlay::Compose(c)) => {
if !c.sent.sending {
(c.discard_armed, c.sent.error) = (None, None);
c.input.insert_str(&text);
}
}
Some(Overlay::Submit(d)) => {
if !d.sent.sending {
(d.discard_armed, d.sent.error) = (None, None);
d.input.insert_str(&text);
}
}
Some(Overlay::Find(input)) => {
input.insert_str(line());
}
Some(Overlay::Search(sb)) => {
sb.input.insert_str(line());
return crate::nav::search_box_changed(sb);
}
Some(Overlay::Picker(p)) if !matches!(p.kind, picker::Kind::Commits { .. }) => {
p.input.insert_str(line());
p.selected = 0;
}
Some(Overlay::Menu(menu)) if menu.filter.is_some() => {
if let Some(filter) = &mut menu.filter {
filter.push_str(&line());
}
menu.reselect();
}
_ => state.info("Nothing to paste into here: a paste goes into a comment or a box"),
}
Vec::new()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Completing {
pub selected: usize,
dismissed: Option<(usize, usize)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Choice {
pub label: String,
pub insert: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Typed {
sigil: char,
at: (usize, usize),
query: String,
}
fn typed(input: &TextArea<'_>) -> Option<Typed> {
let cursor = input.cursor();
let (row, col) = (cursor.0, cursor.1);
let before: Vec<char> = input.lines().get(row)?.chars().take(col).collect();
let word = |c: &char| c.is_alphanumeric() || matches!(c, '-' | '_');
let query_len = before.iter().rev().take_while(|c| word(c)).count();
let at = col.checked_sub(query_len.checked_add(1)?)?;
let sigil = *before.get(at)?;
let starts_word = at
.checked_sub(1)
.and_then(|i| before.get(i))
.is_none_or(|c| !word(c) && !matches!(c, '@' | '#' | '/' | '.'));
if !matches!(sigil, '@' | '#') || !starts_word {
return None;
}
let query = before.get(at.saturating_add(1)..)?.iter().collect();
Some(Typed {
sigil,
at: (row, at),
query,
})
}
fn repo_here(state: &State) -> Option<RepoId> {
match state.screen() {
Screen::Diff(screen) => Some(screen.of.repo().clone()),
Screen::Page(p) => p.route.repo().cloned(),
}
}
fn participants(state: &State) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let pr = match (state.screen(), state.route()) {
(Screen::Diff(screen), _) => screen.of.pr().cloned(),
(_, Some(Route::Pulls(pulls::Pr { pr, .. }))) => Some(pr.clone()),
_ => None,
};
if let Some(pr) = &pr {
out.extend(pr_participants(state, pr));
}
if let Some(Route::Issues(issues::Issue { repo, number })) = state.route() {
let issue =
state.picked::<Option<Box<IssueDetail>>>(&DataKey::Issue(repo.clone(), *number));
if let Some(Some(issue)) = issue {
out.push(issue.author.clone());
out.extend(issue.comments().map(|c| c.author.clone()));
out.extend(issue.assignees.iter().cloned());
}
}
let mut seen = std::collections::HashSet::new();
out.retain(|login| !login.is_empty() && seen.insert(login.to_lowercase()));
out
}
fn pr_participants(state: &State, pr: &PrRef) -> Vec<String> {
let mut out = Vec::new();
if let Some(d) = state.pr(pr) {
out.push(d.author.clone());
}
if let Some(a) = state.picked::<PrActivity>(&DataKey::PrActivity(pr.clone())) {
out.extend(a.comments().map(|c| c.author.clone()));
out.extend(a.reviews().map(|r| r.author.clone()));
out.extend(a.requested.iter().filter_map(|r| match &r.who {
crate::api::browse::Reviewer::User(login) => Some(login.clone()),
crate::api::browse::Reviewer::Team(_) => None,
}));
}
if let Some(diff) = state.diffs.get(&DiffOf::Pr(pr.clone())) {
let threads = diff.inputs().thread_list();
out.extend(
threads
.iter()
.flat_map(|t| t.comments.iter().map(|c| c.author.clone())),
);
}
out
}
pub fn completions(state: &State) -> Option<Vec<Choice>> {
let Some(Overlay::Compose(compose)) = &state.overlay else {
return None;
};
let typed = typed(&compose.input)?;
if compose.completing.dismissed == Some(typed.at) {
return None;
}
let known =
repo_here(state).and_then(|repo| state.picked::<Mentionable>(&DataKey::Mentionable(repo)));
let query = typed.query.to_lowercase();
let choices: Vec<Choice> = if typed.sigil == '@' {
let mut people = participants(state);
for login in known.iter().flat_map(|m| &m.users) {
if !people.iter().any(|p| p.eq_ignore_ascii_case(login)) {
people.push(login.clone());
}
}
let (mut first, rest): (Vec<String>, Vec<String>) = people
.into_iter()
.filter(|login| login.to_lowercase().contains(&query))
.partition(|login| login.to_lowercase().starts_with(&query));
first.extend(rest);
first
.into_iter()
.map(|login| Choice {
label: format!("@{login}"),
insert: format!("@{login} "),
})
.collect()
} else {
let issues = known.iter().flat_map(|m| &m.issues);
issues
.filter(|(number, title)| {
number.to_string().starts_with(&query) || title.to_lowercase().contains(&query)
})
.map(|(number, title)| Choice {
label: format!("#{number} {title}"),
insert: format!("#{number} "),
})
.collect()
};
(!choices.is_empty()).then(|| choices.into_iter().take(SHOWN).collect())
}
pub fn on_compose_key(state: &mut State, key: KeyEvent) -> Option<Vec<Cmd>> {
let choices = completions(state)?;
let Some(Overlay::Compose(compose)) = &mut state.overlay else {
return None;
};
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let last = choices.len().saturating_sub(1);
let completing = &mut compose.completing;
match key.code {
KeyCode::Down => completing.selected = (completing.selected + 1).min(last),
KeyCode::Char('n') if ctrl => completing.selected = (completing.selected + 1).min(last),
KeyCode::Up => completing.selected = completing.selected.saturating_sub(1),
KeyCode::Char('p') if ctrl => completing.selected = completing.selected.saturating_sub(1),
KeyCode::Esc => completing.dismissed = typed(&compose.input).map(|t| t.at),
KeyCode::Tab | KeyCode::Enter if key.modifiers.is_empty() => {
let choice = choices.get(completing.selected.min(last))?;
let typed = typed(&compose.input)?;
for _ in 0..=typed.query.chars().count() {
compose.input.delete_char();
}
compose.input.insert_str(&choice.insert);
compose.completing = Completing::default();
}
_ => return None,
}
Some(Vec::new())
}
#[must_use]
pub fn fetch_mentionable(state: &mut State) -> Vec<Cmd> {
match repo_here(state) {
Some(repo) => state.ensure(DataKey::Mentionable(repo), false),
None => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(text: &str) -> Option<(char, (usize, usize), String)> {
let mut input = TextArea::default();
input.insert_str(text);
typed(&input).map(|t| (t.sigil, t.at, t.query))
}
#[test]
fn only_a_word_that_starts_with_the_sigil_completes() {
assert_eq!(at("cc @oc"), Some(('@', (0, 3), "oc".into())));
assert_eq!(at("@"), Some(('@', (0, 0), String::new())));
assert_eq!(at("see\n(#12"), Some(('#', (1, 1), "12".into())));
assert_eq!(at("mail a@b"), None);
assert_eq!(at("C#"), None);
assert_eq!(at("@octo cat"), None);
}
}