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::{Cmds, Overlay, Screen, State};
pub const SHOWN: usize = 6;
pub fn on_paste(state: &mut State, text: &str) -> Cmds {
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"),
}
Cmds::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<&str> {
let mut out: Vec<&str> = 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 {
pr_participants(state, pr, &mut out);
}
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);
out.extend(issue.comments().map(|c| c.author.as_str()));
out.extend(issue.assignees.iter().map(String::as_str));
}
}
let mut seen = std::collections::HashSet::new();
out.retain(|login| !login.is_empty() && seen.insert(login.to_lowercase()));
out
}
fn pr_participants<'a>(state: &'a State, pr: &PrRef, out: &mut Vec<&'a str>) {
if let Some(d) = state.pr(pr) {
out.push(&d.author);
}
if let Some(a) = state.picked::<PrActivity>(&DataKey::PrActivity(pr.clone())) {
out.extend(a.comments().map(|c| c.author.as_str()));
out.extend(a.reviews().map(|r| r.author.as_str()));
out.extend(a.requested.iter().filter_map(|r| match &r.who {
crate::api::browse::Reviewer::User(login) => Some(login.as_str()),
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.as_str())),
);
}
}
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.as_str();
let choices: Vec<Choice> = if typed.sigil == '@' {
let mut people = participants(state);
let taking = people.len();
for login in known.iter().flat_map(|m| &m.users) {
if !people
.iter()
.take(taking)
.any(|p| p.eq_ignore_ascii_case(login))
{
people.push(login);
}
}
let (first, rest): (Vec<_>, Vec<_>) = (people.into_iter())
.filter_map(|login| Some((picker::find_ci(login, query)?, login)))
.partition(|(at, _)| *at == 0);
(first.into_iter().chain(rest))
.take(SHOWN)
.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) || picker::find_ci(title, query).is_some()
})
.take(SHOWN)
.map(|(number, title)| Choice {
label: format!("#{number} {title}"),
insert: format!("#{number} "),
})
.collect()
};
(!choices.is_empty()).then_some(choices)
}
pub fn on_compose_key(state: &mut State, key: KeyEvent) -> Option<Cmds> {
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(Cmds::new())
}
pub fn fetch_mentionable(state: &mut State) -> Cmds {
match repo_here(state) {
Some(repo) => state.ensure(DataKey::Mentionable(repo), false),
None => Cmds::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);
}
}