use std::mem;
use std::sync::Arc;
use crate::api::browse::{DiscussionsOf, RepoSummary, SearchKind};
use crate::api::change::Change;
use crate::api::model::RepoId;
use crate::diff::anchor::Side;
use crate::theme::{Bg, Theme};
use crate::ui::chrome::{self, KeyRow, SuggestRow};
use crate::ui::diff_doc::Row;
use crate::ui::page::{self, HintLabel, Item, Link, Page};
use crate::ui::pages::{self, PrTab, ProfileTab};
use crate::ui::{PAD_X, PAD_Y};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
use ratatui_textarea::TextArea;
use serde::{Deserialize, Serialize};
use crate::browse::{self, Data, DataKey, Need};
use crate::diff_screen::DiffOf;
use crate::keymap::Action;
use crate::picker::{fuzzy_score, move_in_list};
use crate::review::ComposeTarget;
use crate::route::{self, Dest, Route, Target};
use crate::state::{Api, Cmd, Overlay, Remote, Screen, State, apply};
const STEP: isize = 3;
const MARGIN: usize = 2;
const VISITS: usize = 300;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Visit {
pub url: String,
pub title: String,
pub count: u32,
pub last: u64,
}
impl Visit {
fn score(&self, now: u64) -> u64 {
let days = now.saturating_sub(self.last) / 86_400;
u64::from(self.count) * 100 / (days + 1)
}
}
impl State {
#[must_use]
pub(crate) fn record_visit(&mut self, route: &Route) -> Vec<Cmd> {
if matches!(route, Route::Home | Route::Unsplit { .. }) {
return Vec::new();
}
let url = route.url();
let now = (self.clock)();
match self.visits.iter_mut().find(|v| v.url == url) {
Some(v) => {
v.count += 1;
v.last = now;
v.title = route.title();
}
None => self.visits.push(Visit {
url,
title: route.title(),
count: 1,
last: now,
}),
}
if self.visits.len() > VISITS {
self.visits.sort_by_key(|v| std::cmp::Reverse(v.score(now)));
self.visits.truncate(VISITS);
}
vec![Cmd::Api(Api::SaveVisits(self.visits.clone()))]
}
#[must_use]
pub fn push(&mut self, route: Route) -> Vec<Cmd> {
self.go(Target::Page(route).into())
}
#[must_use]
pub fn replace(&mut self, route: Route, force: bool, anchor: Option<String>) -> Vec<Cmd> {
match self.screen_mut() {
Screen::Page(p) => **p = PageScreen::new(route.clone(), anchor),
Screen::Diff(_) => return self.push(route),
}
let mut cmds = self.ensure_route(&route, force);
cmds.extend(self.record_visit(&route));
cmds
}
#[must_use]
pub fn back(&mut self) -> Vec<Cmd> {
if let Some(screen) = self.screens.pop() {
self.forward.push(screen);
return self.load_visible(false);
}
self.info("This is the first page");
Vec::new()
}
#[must_use]
pub fn go_forward(&mut self) -> Vec<Cmd> {
match self.forward.pop() {
Some(screen) => {
self.screens.push(screen);
self.load_visible(false)
}
None => {
self.info("Nothing to go forward to");
Vec::new()
}
}
}
#[must_use]
pub fn go(&mut self, Dest { target, anchor }: Dest) -> Vec<Cmd> {
match target {
Target::Page(route) => {
let route = self.followed(route);
self.forward.clear();
let mut cmds = self.record_visit(&route);
let screen = PageScreen::new(route, anchor);
self.screens.push(Screen::Page(Box::new(screen)));
if let Some(route) = self.route().cloned() {
cmds.extend(self.ensure_route(&route, true));
}
cmds
}
Target::Files(of) => {
self.forward.clear();
let mut cmds = match &of {
DiffOf::Pr(pr) => self.ensure(Need::Pr(pr.clone()), false),
DiffOf::Commit(..) | DiffOf::Range(..) => Vec::new(),
};
cmds.extend(self.open_diff(of, anchor));
cmds
}
Target::External(url) => {
self.info(format!("Opened {url} in the browser"));
vec![Cmd::OpenUrl(url)]
}
}
}
#[must_use]
pub fn follow(&mut self, link: &Link) -> Vec<Cmd> {
match link {
Link::Url(url) => self.go(Dest::from_url(url)),
Link::More => self.load_more(),
Link::Star => star(self),
Link::Comment => comment_with(self, ""),
Link::Filter => self.open_search(),
Link::Sort => cycle_sort(self),
Link::Branch => self.open_finder(true),
Link::FindFile => self.open_finder(false),
Link::Section(i) => self.open_section(*i),
Link::State(state) => set_list_state(self, state),
Link::Quote { author, body, .. } => {
let quoted: String = body.trim().lines().map(|l| format!("> {l}\n")).collect();
comment_with(self, &format!("> @{author} wrote:\n{quoted}\n"))
}
}
}
#[must_use]
fn load_more(&mut self) -> Vec<Cmd> {
let Some(key) = self.route().and_then(browse::paged) else {
return Vec::new();
};
let after = self.data.get_mut(&key).and_then(Remote::ask_more);
after.map_or_else(Vec::new, |after| {
vec![Cmd::Api(Api::FetchMore { key, after })]
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageScreen {
pub route: Route,
pub scroll: usize,
pub selected: Option<usize>,
page: Arc<Page>,
built: Option<(u64, u16, bool)>,
fresh: bool,
jumped: Option<Option<(usize, Option<usize>)>>,
pub anchor: Option<String>,
mark: Option<usize>,
}
impl PageScreen {
pub fn new(route: Route, anchor: Option<String>) -> Self {
let list = !matches!(
route,
Route::Issue { .. }
| Route::Blob { .. }
| Route::Blame { .. }
| Route::Commit { .. }
| Route::Release { .. }
| Route::Discussion { .. }
| Route::Job { .. }
| Route::Pr {
tab: PrTab::Conversation,
..
}
);
Self {
route,
scroll: 0,
selected: None,
page: Arc::new(Page::default()),
built: None,
fresh: list,
jumped: None,
anchor,
mark: None,
}
}
pub fn page(&self) -> &Page {
&self.page
}
fn show(&mut self, page: Page, built: (u64, u16, bool)) {
let old = mem::replace(&mut self.page, Arc::new(page));
self.built = Some(built);
let before = (self.scroll, self.selected);
let find = |i: usize| self.page.find(old.target(old.items.get(i)?.link)?, i);
let last = self.page.items.len().checked_sub(1);
self.selected = (self.selected.filter(|_| !self.fresh))
.and_then(|i| find(i).or_else(|| last.map(|l| i.min(l))));
let off = |it: &Item| it.start.abs_diff(self.scroll);
let mut near: Vec<_> = old.items.iter().enumerate().collect();
near.sort_by_key(|&(_, it)| off(it));
let mut kept = near.into_iter().filter(|&(_, it)| off(it) < self.scroll);
let moved = kept.find_map(|(i, it)| Some((it, self.page.items.get(find(i)?)?)));
if let Some((it, now)) = moved {
self.scroll = (self.scroll + now.start).saturating_sub(it.start);
}
if self.jumped == Some(Some(before)) {
self.jumped = Some(Some((self.scroll, self.selected)));
}
}
fn mark_at(&self) -> Option<usize> {
let marks = &self.page.marks;
(self.mark.filter(|&m| m < marks.len()))
.or_else(|| marks.iter().position(|&m| Some(m) == self.page.jump))
}
pub fn current_match(&self) -> Option<usize> {
self.page.marks.get(self.mark_at()?).copied()
}
pub fn selected_link(&self) -> Option<&Link> {
let item = self.page.items.get(self.selected?)?;
self.page.target(item.link)
}
}
impl State {
pub fn page_area(&self) -> Rect {
let c = self.layout().content;
Rect {
y: c.y + PAD_Y.min(c.height),
height: c.height.saturating_sub(PAD_Y),
..c
}
}
pub fn page_width(&self) -> u16 {
self.size.0.saturating_sub(2 * PAD_X + 1)
}
pub fn page_height(&self) -> usize {
usize::from(self.page_area().height).max(1)
}
pub fn sync_page(&mut self) {
let width = self.page_width();
let built = (self.data_gen, width, self.compact());
let height = self.page_height();
let rebuild = match self.screen() {
Screen::Page(p) if p.built != Some(built) => Some(p.route.clone()),
Screen::Page(_) => None,
Screen::Diff(_) => return,
};
let page = rebuild.map(|route| self.build_page(&route, width, (self.clock)()));
if let Screen::Page(p) = self.screen_mut() {
if let Some(page) = page {
p.show(page, built);
}
settle(p, height);
}
}
}
fn visible(p: &PageScreen, item: usize, height: usize) -> bool {
p.page
.items
.get(item)
.is_some_and(|i| i.start < p.scroll + height && i.end > p.scroll)
}
fn settle(p: &mut PageScreen, height: usize) {
let anchor = p
.anchor
.as_ref()
.and_then(|a| p.page.anchors.get(a))
.copied();
let max = p.page.height().saturating_sub(height);
let here = Some((p.scroll, p.selected));
let jump = (p.page.jump.or(anchor)).filter(|_| p.jumped.is_none_or(|at| at == here));
if let Some(jump) = jump {
p.scroll = jump.saturating_sub(MARGIN);
}
p.scroll = p.scroll.min(max);
if p.fresh && p.selected.is_none() {
let shown = |i: &usize| visible(p, *i, height);
let row = |i: &usize| {
let link = p.page.items.get(*i).and_then(|it| p.page.target(it.link));
!matches!(link, Some(Link::Section(_)))
};
let n = p.page.items.len();
p.selected = (0..n)
.filter(shown)
.find(row)
.or_else(|| (0..n).find(shown));
}
if jump.is_some() || p.jumped.is_some() {
p.jumped = Some(jump.map(|_| (p.scroll, p.selected)));
}
}
fn reveal(p: &mut PageScreen, item: usize, height: usize) {
if let Some(i) = p.page.items.get(item) {
p.scroll = page::reveal(i.start, i.end, p.scroll, height, p.page.height(), MARGIN);
}
}
fn step(p: &mut PageScreen, height: usize, down: bool) {
p.fresh = false;
let n = p.page.items.len();
let items = &p.page.items;
let target = match p.selected.map(|i| (i, items.get(i))) {
Some((_, Some(item))) if down && item.end > p.scroll + height => None,
Some((_, Some(item))) if !down && item.start < p.scroll => None,
Some((i, _)) if down => (i + 1 < n).then_some(i + 1),
Some((i, _)) => i.checked_sub(1),
None if down => (0..n).find(|&i| visible(p, i, height)),
None => (0..n).rev().find(|&i| visible(p, i, height)),
};
match target {
Some(i) => {
p.selected = Some(i);
reveal(p, i, height);
}
None if down => scroll_by(p, STEP, height),
None => scroll_by(p, -STEP, height),
}
}
fn scroll_by(p: &mut PageScreen, rows: isize, height: usize) {
p.fresh = false;
let max = p.page.height().saturating_sub(height);
p.scroll = p.scroll.saturating_add_signed(rows).min(max);
if p.selected.is_some_and(|s| !visible(p, s, height)) {
p.selected = None;
}
}
fn scroll_keep(p: &mut PageScreen, rows: isize, height: usize) {
let had = p.selected.is_some();
let scroll = p.scroll;
scroll_by(p, rows, height);
let stuck = p.scroll == scroll;
if (had && p.selected.is_none()) || stuck {
let mut visible_items = (0..p.page.items.len()).filter(|&i| visible(p, i, height));
p.selected = if (rows > 0) == stuck {
visible_items.next_back()
} else {
visible_items.next()
};
}
}
#[must_use]
pub fn page_action(state: &mut State, action: Action) -> Vec<Cmd> {
let height = state.page_height();
let Screen::Page(p) = state.screen_mut() else {
return Vec::new();
};
let half = (height / 2).max(1).cast_signed();
let page = height.saturating_sub(2).max(1).cast_signed();
match action {
Action::Down => step(p, height, true),
Action::Up => step(p, height, false),
Action::HalfPageDown => scroll_keep(p, half, height),
Action::HalfPageUp => scroll_keep(p, -half, height),
Action::PageDown => scroll_keep(p, page, height),
Action::PageUp => scroll_keep(p, -page, height),
Action::Top => {
scroll_by(p, isize::MIN, height);
p.selected = (!p.page.items.is_empty() && visible(p, 0, height)).then_some(0);
}
Action::Bottom => {
scroll_by(p, isize::MAX, height);
let last = p.page.items.len().checked_sub(1);
p.selected = last.filter(|&l| visible(p, l, height));
}
Action::Open => {
return match p.selected_link().cloned() {
Some(link) => state.follow(&link),
None => {
let (down, hints) = (
state.first_key(Action::Down),
state.first_key(Action::Hints),
);
state.info(format!(
"Nothing selected: {down} selects a row, {hints} picks any link"
));
Vec::new()
}
};
}
Action::Hints | Action::HintsBrowser => {
start_hints(state, action == Action::HintsBrowser);
}
Action::NextHunk | Action::PrevHunk => {
let next = action == Action::NextHunk;
match step_mark(p, next) {
Some((n, of)) => state.info(format!("Match {n} of {of}")),
None if p.page.marks.is_empty() => {
let query = match &p.route {
Route::Job { query, .. } => Some(query.clone()),
_ => None,
};
let search = state.first_key(Action::Search);
state.info(match query {
Some(q) if q.is_empty() => {
format!("No search yet: {search} searches the log")
}
Some(q) => format!("No matches for “{q}”"),
None => format!("No matches here: {search} searches a job's log"),
});
}
None => state.info(if next {
"No later match"
} else {
"No earlier match"
}),
}
}
Action::Star => return star(state),
Action::Comment => return comment_with(state, ""),
Action::Branch => return state.open_finder(true),
Action::ToggleState => return cycle_state(state),
Action::Sort => return cycle_sort(state),
Action::SaveSection
| Action::RenameSection
| Action::MoveSectionUp
| Action::MoveSectionDown
| Action::Delete
| Action::UndoDelete => return crate::home::act(state, action),
_ => state.info(action.not_here()),
}
Vec::new()
}
fn step_mark(p: &mut PageScreen, next: bool) -> Option<(usize, usize)> {
let n = match (p.mark_at(), next) {
(None, true) => 0,
(None, false) => return None,
(Some(at), true) => at + 1,
(Some(at), false) => at.checked_sub(1)?,
};
let marks = &p.page.marks;
p.scroll = marks.get(n)?.saturating_sub(MARGIN);
p.mark = Some(n);
p.fresh = false;
Some((n + 1, marks.len()))
}
#[must_use]
pub fn up_level(state: &mut State) -> Vec<Cmd> {
let Some(route) = state.route().cloned() else {
return Vec::new();
};
match up(state, &route) {
Some(parent) => state.push(parent),
None => {
state.info("Already at the top");
Vec::new()
}
}
}
fn up(state: &State, route: &Route) -> Option<Route> {
let default = |repo: &RepoId| state.overview(repo).and_then(|o| o.default_branch.clone());
let folder = |repo: &RepoId, rev: &str, path: &str| {
let parent = path.rsplit_once('/').map_or("", |(p, _)| p);
if parent.is_empty() && default(repo).as_deref() == Some(rev) {
Route::Repo(repo.clone())
} else {
Route::Tree {
repo: repo.clone(),
rev: rev.to_owned(),
path: parent.to_owned(),
}
}
};
Some(match route {
Route::Home => return None,
Route::Repo(repo) => Route::user(&repo.owner),
Route::Tree { repo, path, .. } if path.is_empty() => Route::Repo(repo.clone()),
Route::Tree { repo, rev, path }
| Route::Blob {
repo, rev, path, ..
} => folder(repo, rev, path),
Route::Blame {
repo, rev, path, ..
} => Route::blob(repo.clone(), rev.clone(), path.clone()),
Route::Issues { repo, .. }
| Route::Discussions {
of: DiscussionsOf::Repo(repo),
..
}
| Route::Pulls { repo, .. }
| Route::Commits { repo, .. }
| Route::Unsplit { repo, .. }
| Route::Stargazers(repo)
| Route::Watchers(repo)
| Route::Forks(repo)
| Route::Releases(repo)
| Route::Tags(repo)
| Route::Branches(repo)
| Route::Compare { repo, .. }
| Route::Deployments { repo, .. }
| Route::Milestones { repo, .. }
| Route::Wiki { repo, page: None }
| Route::Actions(repo) => Route::Repo(repo.clone()),
Route::Discussion { of, .. } => Route::Discussions {
of: of.clone(),
category: None,
},
Route::Discussions {
of: DiscussionsOf::Org(org),
..
} => Route::user(org),
Route::Release { repo, .. } => Route::Releases(repo.clone()),
Route::Milestone { repo, .. } => Route::Milestones {
repo: repo.clone(),
closed: false,
},
Route::WorkflowRun { repo, .. }
| Route::Workflow { repo, .. }
| Route::Job {
repo, run: None, ..
} => Route::Actions(repo.clone()),
Route::Job {
repo,
run: Some(run),
..
} => Route::WorkflowRun {
repo: repo.clone(),
run: *run,
attempt: None,
},
Route::CommitChecks { repo, oid } => Route::Commit {
repo: repo.clone(),
oid: oid.clone(),
},
Route::Commit { repo, oid } => Route::Commits {
repo: repo.clone(),
rev: oid.clone(),
path: String::new(),
},
Route::Issue { repo, .. } => Route::Issues {
repo: repo.clone(),
query: crate::route::OPEN.into(),
},
Route::Pr { pr, .. } => Route::Pulls {
repo: pr.repo.clone(),
query: crate::route::OPEN.into(),
},
Route::User { .. } | Route::Search { .. } => Route::Home,
Route::Gist { owner, .. } => Route::Gists(owner.clone()?),
Route::Gists(login) | Route::Teams(login) => Route::user(login),
Route::Team { org, .. } => Route::Teams(org.clone()),
Route::Wiki {
repo,
page: Some(_),
} => Route::Wiki {
repo: repo.clone(),
page: None,
},
Route::Advisory { repo, .. } => Route::Advisories(repo.clone()),
Route::Advisories(repo) => repo.clone().map_or(Route::Home, Route::Repo),
})
}
#[must_use]
pub fn switch_tab(state: &mut State, n: usize) -> Vec<Cmd> {
let chrome = state.chrome();
let Some((tab, target)) = n.checked_sub(1).and_then(|i| chrome.tabs.get(i)).cloned() else {
state.info(format!("No tab {n} here"));
return Vec::new();
};
if chrome.active == Some(n - 1) {
state.info(format!("Already on {}", tab.label));
return Vec::new();
}
let diff_of = match state.screen() {
Screen::Diff(screen) => Some(screen.of.clone()),
Screen::Page(_) => None,
};
match (target, diff_of) {
(Target::Page(route), None) => state.replace(route, false, None),
(Target::Page(route), Some(diff_pr)) => {
state.screens.pop();
let same = match (state.route(), &diff_pr) {
(Some(Route::Pr { pr, .. }), DiffOf::Pr(diff)) => pr == diff,
(Some(Route::Commit { repo, oid }), DiffOf::Commit(r, full)) => {
repo == r && full.starts_with(oid.as_str())
}
(Some(Route::Compare { repo, .. }), DiffOf::Range(r, ..)) => repo == r,
_ => false,
};
if same {
state.replace(route, false, None)
} else {
state.push(route)
}
}
(target, _) => state.go(target.into()),
}
}
#[must_use]
pub fn step_tab(state: &mut State, forward: bool) -> Vec<Cmd> {
let chrome = state.chrome();
let n = chrome.tabs.len();
let next = chrome
.active
.filter(|_| n > 1)
.map(|active| (if forward { active + 1 } else { active + n - 1 }) % n);
match next {
Some(i) => switch_tab(state, i + 1),
None => {
state.info("No other tabs here");
Vec::new()
}
}
}
pub struct Hints {
pub labels: Vec<HintLabel>,
pub links: Vec<Link>,
pub typed: String,
pub browser: bool,
pub tab: bool,
}
const LETTERS: &str = "asdfghjklqwertyuiopzxcvbnm";
fn hint_labels(n: usize) -> Vec<String> {
let letters: Vec<char> = LETTERS.chars().collect();
if n <= letters.len() {
return letters
.iter()
.take(n)
.map(std::string::ToString::to_string)
.collect();
}
let per = n.div_ceil(letters.len()).min(letters.len());
letters
.iter()
.flat_map(|a| letters.iter().take(per).map(move |b| format!("{a}{b}")))
.take(n)
.collect()
}
fn start_hints(state: &mut State, browser: bool) {
let area = state.page_area();
let Screen::Page(p) = state.screen() else {
return;
};
let spots = page::spots(&p.page, area, p.scroll);
if spots.is_empty() {
state.info("No links on screen");
return;
}
let labels = hint_labels(spots.len());
let (spots, links): (Vec<&page::Spot>, Vec<Link>) = spots
.iter()
.filter_map(|s| Some((s, p.page.target(s.link)?.clone())))
.unzip();
let labels = spots
.iter()
.zip(labels)
.map(|(s, label)| HintLabel {
x: s.x,
y: s.y,
label,
})
.collect();
state.overlay = Some(Overlay::Hints(Box::new(Hints {
labels,
links,
typed: String::new(),
browser,
tab: false,
})));
}
#[must_use]
pub fn on_hints_key(state: &mut State, key: KeyEvent) -> Vec<Cmd> {
let Some(Overlay::Hints(hints)) = &mut state.overlay else {
return Vec::new();
};
match key.code {
KeyCode::Backspace => {
hints.typed.pop();
return Vec::new();
}
KeyCode::Char(c) if c.is_ascii_lowercase() => hints.typed.push(c),
KeyCode::Char(c) if c.is_ascii_uppercase() => {
hints.typed.push(c.to_ascii_lowercase());
hints.tab = true;
}
_ => {
state.overlay = None;
return Vec::new();
}
}
let matching: Vec<(&HintLabel, &Link)> = hints
.labels
.iter()
.zip(&hints.links)
.filter(|(h, _)| h.label.starts_with(&hints.typed))
.collect();
match matching.as_slice() {
[] => {
state.overlay = None;
state.info("No link has those letters");
Vec::new()
}
[(hint, link)] if hint.label == hints.typed => {
let link = (*link).clone();
let (browser, tab) = (hints.browser, hints.tab);
state.overlay = None;
match link {
Link::Url(url) if browser => state.go(Target::External(url).into()),
Link::Url(url) if tab => state.open_tab(Dest::from_url(&url)),
link => state.follow(&link),
}
}
_ => Vec::new(),
}
}
impl Hints {
pub fn shown(&self) -> Vec<HintLabel> {
self.labels
.iter()
.filter(|l| l.label.starts_with(&self.typed))
.cloned()
.collect()
}
}
pub struct SearchBox {
pub input: TextArea<'static>,
pub filter: bool,
pub selected: usize,
remote: Vec<RepoSummary>,
remote_for: String,
}
impl SearchBox {
pub(crate) fn query(&self) -> String {
self.input.lines().join("").trim().to_owned()
}
pub(crate) fn suggested(&mut self, q: String, repos: Vec<RepoSummary>) {
if q == self.query() {
(self.remote, self.remote_for) = (repos, q);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pick {
Go(Dest),
Search(SearchKind, String),
Filter(String),
}
pub fn type_into(input: &mut TextArea<'_>, key: KeyEvent) {
if key.code == KeyCode::Char('u') && key.modifiers.contains(KeyModifiers::CONTROL) {
input.delete_line_by_head();
} else {
input.input(key);
}
}
pub fn new_input(theme: &Theme, bg: Bg) -> TextArea<'static> {
let mut input = TextArea::default();
input.set_style(theme.body(bg));
input.set_cursor_line_style(theme.body(bg));
input.set_cursor_style(theme.fill(Bg::Primary));
input
}
impl State {
#[must_use]
pub fn open_search(&mut self) -> Vec<Cmd> {
let filter_query = match self.route() {
Some(Route::Job { query, .. }) => Some(query.clone()),
route => route.and_then(Route::list_query).map(str::to_owned),
};
let mut input = new_input(&self.theme, Bg::ContainerHighest);
if let Some(q) = &filter_query {
input.insert_str(q);
input.insert_char(' ');
}
if let Some(Route::User {
login,
tab: ProfileTab::Repositories(_),
}) = self.route()
{
input.insert_str(format!("user:{login} "));
}
self.overlay = Some(Overlay::Search(Box::new(SearchBox {
input,
filter: filter_query.is_some(),
selected: 0,
remote: Vec::new(),
remote_for: String::new(),
})));
Vec::new()
}
pub fn suggestions(&self, sb: &SearchBox) -> Vec<(SuggestRow, Option<Pick>)> {
let q: &str = &sb.query();
let mut out = Vec::new();
if sb.filter {
if let Some(Route::Job { .. }) = self.route() {
let pick = Pick::Filter(q.to_owned());
let what = if q.is_empty() {
"clear the search"
} else {
"search the log"
};
out.push(suggestion("⌕", q, what, "↵", pick));
return out;
}
if !q.is_empty() {
let pick = Pick::Filter(q.to_owned());
out.push(suggestion("⌕", q, "filter this list", "↵", pick));
}
out.push(heading("Quick filters"));
for (f, what) in [
("is:open", "open"),
("is:closed", "closed"),
("is:open author:@me", "yours"),
("is:open assignee:@me", "assigned to you"),
("is:open mentions:@me", "mentioning you"),
("is:open no:assignee", "unassigned"),
] {
out.push(suggestion("⚑", f, what, "", Pick::Filter(f.to_owned())));
}
if !q.is_empty() {
out.push(heading("Search all of GitHub"));
let pick = Pick::Search(SearchKind::Issues, q.to_owned());
out.push(suggestion("⌕", q, "issues and pull requests", "", pick));
}
return out;
}
if let Some(dest) = route::parse_input(q, self.context_repo()) {
let label = match &dest.target {
Target::Page(r) => r.title(),
Target::Files(of) => format!("Files changed in {of}"),
Target::External(url) => url.clone(),
};
out.push(suggestion("→", label, "go to", "↵", Pick::Go(dest)));
}
let current = self.route().map(Route::url);
let now = (self.clock)();
let mut visits: Vec<&Visit> = self
.visits
.iter()
.filter(|v| Some(&v.url) != current.as_ref())
.collect();
visits.sort_by_key(|v| std::cmp::Reverse(v.score(now)));
let mut jumps: Vec<(usize, String, String, &str, &str)> = visits
.into_iter()
.filter_map(|v| {
let score = fuzzy_score(q, &v.title)?;
Some((score, v.title.clone(), v.url.clone(), "◷", "visited"))
})
.collect();
for r in self.home_repos() {
let name = r.repo.to_string();
if let Some(score) = fuzzy_score(q, &name) {
let url = pages::url::repo(&r.repo);
jumps.push((score + 1, name, url, "▤", "on your Home"));
}
}
if !q.is_empty() {
jumps.sort_by_key(|(score, ..)| *score);
}
let most = if q.is_empty() { 8 } else { 5 };
let mut seen: Vec<String> = Vec::new();
for (_, title, url, icon, detail) in jumps {
if seen.contains(&url) || seen.len() >= most {
continue;
}
if seen.is_empty() {
out.push(heading(if q.is_empty() { "Recent" } else { "Jump to" }));
}
let pick = Pick::Go(Dest::from_url(&url));
out.push(suggestion(icon, title, detail, "", pick));
seen.push(url);
}
if !q.is_empty() {
out.push(heading("Search"));
if let Some(repo) = self.context_repo() {
let pick = Pick::Search(SearchKind::Issues, format!("repo:{repo} {q}"));
out.push(suggestion("⌕", q, &format!("in {repo}"), "", pick));
}
for (kind, what) in [
(SearchKind::Repos, "repositories"),
(SearchKind::Issues, "issues"),
(SearchKind::Pulls, "pull requests"),
(SearchKind::Users, "users"),
] {
let pick = Pick::Search(kind, q.to_owned());
out.push(suggestion("⌕", q, what, "", pick));
}
}
if sb.remote_for == q && !q.is_empty() {
let live: Vec<&RepoSummary> = sb
.remote
.iter()
.filter(|r| !seen.contains(&pages::url::repo(&r.repo)))
.collect();
if !live.is_empty() {
out.push(heading("Repositories"));
}
for r in live {
let detail = format!("☆ {}", pages::compact(r.stars));
let pick = Pick::Go(Target::Page(Route::Repo(r.repo.clone())).into());
out.push(suggestion("▤", r.repo.to_string(), &detail, "", pick));
}
}
out
}
}
fn suggestion(
icon: &str,
label: impl Into<String>,
detail: &str,
hint: &str,
pick: Pick,
) -> (SuggestRow, Option<Pick>) {
let row = SuggestRow::Item {
icon: icon.to_owned(),
label: label.into(),
detail: detail.to_owned(),
hint: hint.to_owned(),
};
(row, Some(pick))
}
fn heading(text: &str) -> (SuggestRow, Option<Pick>) {
(SuggestRow::Heading(text.to_owned()), None)
}
#[must_use]
pub fn on_search_box_key(state: &mut State, key: KeyEvent) -> Vec<Cmd> {
let Some(Overlay::Search(sb)) = &state.overlay else {
return Vec::new();
};
let picks: Vec<Pick> = state
.suggestions(sb)
.into_iter()
.filter_map(|(_, p)| p)
.collect();
let Some(Overlay::Search(sb)) = &mut state.overlay else {
return Vec::new();
};
if move_in_list(key, &mut sb.selected, picks.len()) {
return Vec::new();
}
match key.code {
KeyCode::Esc => {
state.overlay = None;
Vec::new()
}
KeyCode::Enter => {
let pick = picks.into_iter().nth(sb.selected);
state.overlay = None;
pick.map_or_else(Vec::new, |p| choose(state, p))
}
_ => {
type_into(&mut sb.input, key);
sb.selected = 0;
let q = sb.query();
if !sb.filter && q.chars().count() >= 2 && route::parse_input(&q, None).is_none() {
vec![Cmd::SuggestLater(q)]
} else {
Vec::new()
}
}
}
}
#[must_use]
fn choose(state: &mut State, pick: Pick) -> Vec<Cmd> {
match pick {
Pick::Go(dest) => state.go(dest),
Pick::Search(kind, query) => state.push(Route::Search { kind, query }),
Pick::Filter(query) if matches!(state.route(), Some(Route::Job { .. })) => {
search_log(state, &query);
Vec::new()
}
Pick::Filter(query) => match state.route().and_then(|r| r.with_query(query.clone())) {
Some(route) => state.replace(route, true, None),
None => state.push(Route::Search {
kind: SearchKind::Issues,
query,
}),
},
}
}
pub fn search_log(state: &mut State, query: &str) {
let Screen::Page(p) = state.screen_mut() else {
return;
};
let Route::Job {
repo,
job,
query: q,
..
} = &mut p.route
else {
return;
};
let log = DataKey::JobLog(repo.clone(), *job);
query.clone_into(q);
(p.built, p.mark, p.jumped) = (None, None, None);
state.sync_page();
let Screen::Page(p) = state.screen() else {
return;
};
let loaded = state
.picked::<crate::api::browse::JobLog>(&log)
.is_some_and(|l| !l.running);
let back = state.first_key(Action::Back);
let text = match (p.mark_at(), p.page.marks.len()) {
_ if query.is_empty() => format!("Cleared the search · {back} again goes back"),
(Some(at), of) => format!("Match {} of {of}", at + 1),
_ if loaded => format!("No matches for “{query}”"),
_ => format!("Searches for “{query}” once the log is in"),
};
state.info(text);
}
fn with_state(query: &str, state: &str) -> String {
let mut words: Vec<&str> = query
.split_whitespace()
.filter(|w| !matches!(*w, "is:open" | "is:closed" | "state:open" | "state:closed"))
.collect();
let qualifier = match state {
"open" => Some("is:open"),
"closed" => Some("is:closed"),
_ => None,
};
if let Some(q) = qualifier {
words.insert(0, q);
}
words.join(" ")
}
fn next_list_state(query: &str) -> &'static str {
match pages::list_state(query) {
"open" => "closed",
"closed" => "all",
_ => "open",
}
}
fn list_query(state: &State) -> Option<(Route, String)> {
let route = state.route()?;
Some((route.clone(), route.list_query()?.to_owned()))
}
#[must_use]
fn set_list_state(state: &mut State, which: &str) -> Vec<Cmd> {
let Some((route, query)) = list_query(state) else {
return Vec::new();
};
match route.with_query(with_state(&query, which)) {
Some(r) if r != route => state.replace(r, true, None),
_ => Vec::new(),
}
}
#[must_use]
fn cycle_state(state: &mut State) -> Vec<Cmd> {
let Some((_, query)) = list_query(state) else {
state.info("Only lists have open and closed");
return Vec::new();
};
set_list_state(state, next_list_state(&query))
}
#[must_use]
fn cycle_sort(state: &mut State) -> Vec<Cmd> {
if let Some(Route::User {
login,
tab: ProfileTab::Repositories(sort),
}) = state.route()
{
let next = sort.next();
let route = Route::User {
login: login.clone(),
tab: ProfileTab::Repositories(next),
};
state.info(format!("Sorted: {}", next.label()));
return state.replace(route, true, None);
}
let Some((route, query)) = list_query(state) else {
state.info("Only lists can be sorted");
return Vec::new();
};
let current = pages::sort_label(&query);
let i = pages::SORTS
.iter()
.position(|(_, l)| *l == current)
.unwrap_or(0);
let Some(&(next, label)) = pages::SORTS.iter().cycle().nth(i + 1) else {
return Vec::new();
};
let mut words: Vec<&str> = query
.split_whitespace()
.filter(|w| !w.starts_with("sort:"))
.collect();
let sort = format!("sort:{next}");
if next != "created-desc" {
words.push(&sort);
}
state.info(format!("Sorted: {label}"));
match route.with_query(words.join(" ")) {
Some(r) => state.replace(r, true, None),
None => Vec::new(),
}
}
#[must_use]
pub fn star(state: &mut State) -> Vec<Cmd> {
let Some(repo) = state.route().and_then(Route::repo).cloned() else {
state.info("Open a repository to star it");
return Vec::new();
};
let Some(overview) = state.overview(&repo) else {
state.info("The repository hasn't loaded yet");
return Vec::new();
};
let (id, starred) = (overview.id.clone(), !overview.starred);
set_starred(state, &repo, starred);
let star = Change::Star { repo, id, starred };
vec![Cmd::Api(Api::Change(star, crate::act::By::Star))]
}
pub fn set_starred(state: &mut State, repo: &RepoId, starred: bool) {
if let Some(Remote {
data: Some(Data::Repo(o)),
..
}) = state.data.get_mut(&DataKey::Repo(repo.clone()))
&& o.starred != starred
{
o.starred = starred;
o.summary.stars = if starred {
o.summary.stars + 1
} else {
o.summary.stars.saturating_sub(1)
};
}
state.data_gen += 1;
}
#[must_use]
fn comment_with(state: &mut State, text: &str) -> Vec<Cmd> {
let target = match state.route() {
Some(Route::Issue { repo, number }) => {
let key = DataKey::Issue(repo.clone(), *number);
match state.get(&key) {
Some(Data::Issue(Some(issue))) => {
Some((issue.id.clone(), format!("{repo}#{number}")))
}
_ => None,
}
}
Some(Route::Pr { pr, .. }) => state.activity(pr).map(|a| (a.id.clone(), pr.to_string())),
_ => {
state.info("Comments go on issues and pull requests");
return Vec::new();
}
};
let Some((subject_id, name)) = target else {
state.info("Still loading");
return Vec::new();
};
let target = ComposeTarget::Conversation { subject_id, name };
state.compose(target, text)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Doable {
pub section: &'static str,
pub action: Action,
pub label: String,
pub short: &'static str,
pub unavailable: Option<String>,
}
fn doable(section: &'static str, action: Action, label: &str, short: &'static str) -> Doable {
Doable {
section,
action,
label: label.to_owned(),
short,
unavailable: None,
}
}
const DIFF_DOABLES: &[(&str, &[(Action, &str)])] = {
use Action as A;
&[
(
"Move",
&[
(A::NextHunk, "change"),
(A::PrevHunk, ""),
(A::NextFile, "file"),
(A::PrevFile, ""),
(A::NextUnviewed, ""),
(A::NextThread, ""),
(A::PrevThread, ""),
(A::JumpMove, ""),
(A::OldSide, ""),
(A::NewSide, ""),
(A::FindFile, ""),
(A::SwitchPane, ""),
],
),
(
"View",
&[
(A::ToggleTree, ""),
(A::ToggleSplit, ""),
(A::IgnoreWhitespace, ""),
(A::ExpandContext, ""),
(A::FullFile, ""),
(A::ToggleSinceReview, ""),
(A::PickCommits, ""),
],
),
(
"Review",
&[
(A::ToggleViewed, "viewed"),
(A::MarkReviewed, ""),
(A::Comment, "comment"),
(A::VisualLines, ""),
(A::Suggest, ""),
(A::FileComment, ""),
(A::ResolveThread, ""),
(A::Delete, ""),
(A::UndoDelete, ""),
(A::SubmitReview, "submit review"),
],
),
]
};
impl State {
pub fn doables(&self) -> Vec<Doable> {
let mut out = Vec::new();
let selected = match self.screen() {
Screen::Page(p) => {
self.page_doables(p, &mut out);
p.selected_link()
}
Screen::Diff(_) => {
for (section, actions) in DIFF_DOABLES {
for &(action, short) in *actions {
out.push(doable(section, action, action.description(), short));
}
}
None
}
};
for (action, label, why) in crate::act::doables(self) {
let short = match action {
Action::Merge => "merge",
Action::Approve => "approve",
Action::Rerun => "re-run",
_ => "",
};
let mut change = doable("Change", action, &label, short);
change.unavailable = why;
out.push(change);
}
let go = |action, label: &str, short| doable("Go", action, label, short);
if !self.chrome().tabs.is_empty() {
out.push(go(Action::NextTab, "Next tab", "tabs"));
}
if let Screen::Diff(_) = self.screen() {
out.push(go(Action::Tab1, "Back to the conversation", ""));
}
let (open, copy) = match selected {
Some(_) => ("Open the selection on GitHub", "Copy the selection's link"),
None => ("Open this page on GitHub", "Copy this page's link"),
};
out.push(go(Action::OpenInBrowser, open, "browser"));
out.push(go(Action::Copy, copy, "copy link"));
if let Screen::Page(_) = self.screen() {
out.push(go(Action::Hints, "Follow any link by its letters", "links"));
out.push(go(Action::UpLevel, "Up a level", ""));
}
let mut back = go(Action::Back, "Back", "");
if self.screens.len() < 2 {
back.unavailable = Some("this is the first page".into());
}
out.push(back);
let mut fwd = go(Action::Forward, "Forward", "");
if self.forward.is_empty() {
fwd.unavailable = Some("nothing to go forward to".into());
}
out.push(fwd);
out.push(go(Action::GoHome, "Home", ""));
out.push(go(Action::Refresh, "Refresh", ""));
out.push(go(Action::CommandPalette, "Command palette", ""));
let mut messages = go(Action::Messages, "Recent messages and errors", "");
if self.notices.log().is_empty() {
messages.unavailable = Some("nothing yet".into());
}
out.push(messages);
let tab = |action, label: &str| doable("Tabs", action, label, "");
out.push(tab(Action::OpenInTab, "Open in a new tab"));
if self.tab_count() > 1 {
out.push(tab(Action::NextOpenTab, "Next open tab"));
out.push(tab(Action::PrevOpenTab, "Previous open tab"));
out.push(tab(Action::CloseTab, "Close the tab"));
}
for action in [
Action::Down,
Action::Up,
Action::Top,
Action::Bottom,
Action::PageDown,
Action::PageUp,
Action::HalfPageDown,
Action::HalfPageUp,
Action::Quit,
] {
out.push(doable("Keys", action, action.description(), ""));
}
out
}
fn page_doables(&self, p: &PageScreen, out: &mut Vec<Doable>) {
let here = |action, label: &str, short| doable("This page", action, label, short);
let mut open = here(Action::Open, "Open the selected row", "open");
match p.selected_link() {
Some(link) => open.label = describe(link),
None => open.unavailable = Some("nothing is selected".into()),
}
out.push(open);
let route = &p.route;
let list = route.search().map(|(_, q)| q);
if let Route::Job { query, .. } = route {
out.push(here(Action::Search, "Search the log", "search"));
let why = if query.is_empty() {
Some("nothing is searched yet".to_owned())
} else if p.page.marks.is_empty() {
Some(format!("no line has “{query}”"))
} else {
None
};
for (action, label) in [
(Action::NextHunk, "Next match"),
(Action::PrevHunk, "Previous match"),
] {
let mut row = here(action, label, "");
row.unavailable.clone_from(&why);
out.push(row);
}
} else {
out.push(match list {
Some(_) => here(Action::Search, "Filter this list", "filter"),
None => here(Action::Search, "Search or jump to…", "search"),
});
}
if let Some(query) = &list {
let show = format!("Show {}", next_list_state(query));
out.push(here(Action::ToggleState, &show, "open/closed"));
out.push(here(Action::Sort, "Change the sort", "sort"));
let mut save = here(Action::SaveSection, "Save this list to Home", "");
save.unavailable = crate::home::unavailable(self, Action::SaveSection);
out.push(save);
}
if *route == Route::Home {
let title = match self.section_here() {
Some((_, s)) => format!("“{}”", s.title),
None => "the section".to_owned(),
};
for (action, label) in [
(Action::RenameSection, format!("Rename {title}")),
(Action::MoveSectionUp, format!("Move {title} up")),
(Action::MoveSectionDown, format!("Move {title} down")),
(Action::Delete, format!("Remove {title} from Home")),
(
Action::UndoDelete,
"Bring back the section removed".to_owned(),
),
] {
let mut row = doable("Home", action, &label, "");
row.unavailable = crate::home::unavailable(self, action);
out.push(row);
}
}
if let Some(repo) = route.repo() {
out.push(here(Action::FindFile, "Go to file", "go to file"));
if matches!(
route,
Route::Repo(_) | Route::Tree { .. } | Route::Blob { .. }
) {
out.push(here(Action::Branch, "Switch branches or tags", "branch"));
let starred = self.overview(repo).is_some_and(|o| o.starred);
let label = if starred { "Unstar" } else { "Star" };
out.push(here(Action::Star, label, "star"));
}
}
if matches!(route, Route::Issue { .. } | Route::Pr { .. }) {
out.push(here(Action::Comment, "Write a comment", "comment"));
}
if let Route::Pr { tab, .. } = route {
if *tab == PrTab::Conversation {
out.push(here(Action::Tab2, "Commits", "commits"));
}
out.push(here(Action::Tab4, "Files changed (review)", "files"));
}
}
pub fn key_hints(&self) -> Vec<(String, String)> {
let pair = |keys: &str, what: &str| (keys.to_owned(), what.to_owned());
match &self.overlay {
Some(Overlay::Search(sb)) => {
let enter = match self.route() {
_ if !sb.filter => "go",
Some(Route::Job { .. }) => "search",
_ => "filter",
};
return vec![pair("↵", enter), pair("↑↓", "choose"), pair("esc", "close")];
}
Some(Overlay::Hints(_)) => {
return vec![pair("a-z", "type a link's letters"), pair("esc", "cancel")];
}
Some(Overlay::Menu(menu)) if menu.filter.is_some() => {
return vec![
pair("↵", "run"),
pair("↑↓", "choose"),
pair("esc", "stop filtering"),
];
}
Some(Overlay::Menu(_)) => {
return vec![
pair("↵", "run"),
pair("key", "run it"),
(self.first_key(Action::Search), "filter".into()),
pair("esc", "close"),
];
}
Some(Overlay::Picker(_)) => {
return vec![
pair("↵", "open"),
pair("↑↓", "choose"),
pair("esc", "close"),
];
}
Some(_) => return Vec::new(),
None => {}
}
let menu = (self.first_key(Action::Menu), "menu".to_owned());
let focused = self.focus_hints();
let usual = self
.doables()
.into_iter()
.filter(|d| d.unavailable.is_none() && !d.short.is_empty())
.filter(|d| !focused.iter().any(|(a, _)| *a == d.action))
.map(|d| (d.action, d.short.to_owned()));
let hints = focused
.iter()
.cloned()
.chain(usual)
.filter_map(|(action, what)| {
let key = match action {
Action::NextTab => Some("←→".to_owned()),
Action::NextHunk => Some("n/p".to_owned()),
action => self.key_here(action),
};
Some((key?, what))
})
.take(7);
std::iter::once(menu).chain(hints).collect()
}
fn focus_hints(&self) -> Vec<(Action, String)> {
let Screen::Diff(screen) = self.screen() else {
return Vec::new();
};
let Some(diff) = self.diffs.get(&screen.of) else {
return Vec::new();
};
let mut out = Vec::new();
let here = diff
.doc
.annotation_under(screen.cursor, screen.half)
.and_then(|i| diff.doc.annotations().get(i as usize));
match here {
Some(a) if a.is_draft() => {
out.push((Action::Open, "edit".to_owned()));
out.push((Action::Delete, "delete".to_owned()));
}
Some(_) => {
out.push((Action::Comment, "reply".to_owned()));
out.push((Action::ResolveThread, "resolve".to_owned()));
}
None => {}
}
if let Some(Row::Split {
left: Some(left),
right: Some(right),
}) = diff.doc.row(screen.cursor)
&& left != right
{
match diff.doc.half_at(screen.cursor, screen.half) {
Some(Side::Left) => out.push((Action::NewSide, "new side".to_owned())),
Some(Side::Right) => out.push((Action::OldSide, "old side".to_owned())),
None => {}
}
}
let pending = diff.inputs().review.pending.len();
if pending > 0 {
out.push((Action::SubmitReview, format!("submit ({pending} pending)")));
}
out
}
pub fn key_here(&self, action: Action) -> Option<String> {
self.keymap
.keys_in(action, self.scope())
.next()
.map(crate::keymap::pretty)
}
}
fn describe(link: &Link) -> String {
match link {
Link::Url(url) => match Dest::from_url(url).target {
Target::Page(r) => format!("Open {}", r.title()),
Target::Files(of) => format!("Files changed in {of}"),
Target::External(u) => format!("Open {u} in the browser"),
},
Link::More => "Load more".into(),
Link::Comment => "Write a comment".into(),
Link::Star => "Star or unstar".into(),
Link::Filter => "Edit the filter".into(),
Link::Sort => "Change the sort".into(),
Link::State(state) => format!("Show {state}"),
Link::Branch => "Switch branches".into(),
Link::FindFile => "Go to file".into(),
Link::Quote { .. } => "Quote reply".into(),
Link::Section(_) => "Open the section's list".into(),
}
}
pub struct Menu {
pub rows: Vec<Doable>,
pub selected: usize,
pub filter: Option<String>,
}
impl Menu {
pub fn shown(&self) -> Vec<&Doable> {
let query = self.filter.as_deref().unwrap_or_default();
self.rows
.iter()
.filter(|d| {
crate::picker::fuzzy_score(query, &d.label).is_some()
|| crate::picker::fuzzy_score(query, d.action.description()).is_some()
})
.collect()
}
fn reselect(&mut self) {
self.selected = self
.shown()
.iter()
.position(|d| d.unavailable.is_none())
.unwrap_or(0);
}
}
pub fn open_menu(state: &mut State) {
let mut menu = Menu {
rows: state.doables(),
selected: 0,
filter: None,
};
menu.reselect();
state.overlay = Some(Overlay::Menu(Box::new(menu)));
}
impl State {
pub fn menu_rows(&self, menu: &Menu) -> (Vec<KeyRow>, usize) {
let mut rows = Vec::new();
let mut selected = 0;
let mut previous = None;
for (i, d) in menu.shown().into_iter().enumerate() {
if previous != Some(d.section) {
previous = Some(d.section);
rows.push(KeyRow::heading(d.section));
}
if i == menu.selected {
selected = rows.len();
}
let key = self.key_here(d.action).unwrap_or_default();
rows.push(match &d.unavailable {
Some(why) => KeyRow {
dim: true,
..KeyRow::key(key, format!("{} ({why})", d.label))
},
None => KeyRow::key(key, &d.label),
});
}
(rows, selected)
}
}
#[must_use]
pub fn on_menu_key(state: &mut State, key: KeyEvent) -> Vec<Cmd> {
let scope = state.scope();
let pressed = crate::keymap::Key::from(key);
let action = state.keymap.resolve(pressed, scope);
let Some(Overlay::Menu(menu)) = &mut state.overlay else {
return Vec::new();
};
let last = menu.shown().len().saturating_sub(1);
let typed = !key.modifiers.contains(KeyModifiers::CONTROL);
let run = match (&mut menu.filter, key.code) {
(_, KeyCode::Down) | (None, KeyCode::Char('j')) => {
menu.selected = (menu.selected + 1).min(last);
return Vec::new();
}
(_, KeyCode::Up) | (None, KeyCode::Char('k')) => {
menu.selected = menu.selected.saturating_sub(1);
return Vec::new();
}
(_, KeyCode::Enter) | (None, KeyCode::Right | KeyCode::Char('l')) => {
menu.shown().get(menu.selected).map(|d| (*d).clone())
}
(Some(query), code) => {
match code {
KeyCode::Char(c) if typed => query.push(c),
KeyCode::Char('u') => query.clear(),
KeyCode::Backspace if query.pop().is_some() => {}
KeyCode::Backspace | KeyCode::Esc => menu.filter = None,
_ => return Vec::new(),
}
menu.reselect();
return Vec::new();
}
(None, _) if action == Some(Action::Search) => {
menu.filter = Some(String::new());
return Vec::new();
}
(None, KeyCode::Esc | KeyCode::Char('q') | KeyCode::Left | KeyCode::Char('h')) => None,
(None, _) if action == Some(Action::Menu) => None,
(None, _) => {
let row = menu
.rows
.iter()
.find(|d| state.keymap.keys_in(d.action, scope).any(|k| k == pressed));
match row {
Some(d) => Some(d.clone()),
None => return Vec::new(),
}
}
};
run_menu_row(state, run)
}
#[must_use]
fn run_menu_row(state: &mut State, row: Option<Doable>) -> Vec<Cmd> {
let Some(d) = row else {
state.overlay = None;
return Vec::new();
};
if let Some(why) = d.unavailable {
state.info(format!("Can't: {why}"));
return Vec::new();
}
state.overlay = None;
apply(state, d.action)
}
impl State {
pub fn here_url(&self) -> String {
let link = match self.screen() {
Screen::Page(p) => p.selected_link().and_then(Link::url),
Screen::Diff(_) => None,
};
link.map_or_else(|| crate::tabs::url(self.screen()), str::to_owned)
}
}
#[must_use]
pub fn copy_link(state: &mut State) -> Vec<Cmd> {
let url = state.here_url();
state.info(format!("Copied {url}"));
vec![Cmd::Copy(url)]
}
#[must_use]
pub fn on_mouse(state: &mut State, ev: MouseEvent) -> Vec<Cmd> {
let (x, y) = (ev.column, ev.row);
match ev.kind {
MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => {
let down = ev.kind == MouseEventKind::ScrollDown;
if state.overlay.is_some() {
return Vec::new();
}
let height = state.page_height();
match state.screen_mut() {
Screen::Page(p) => {
scroll_by(p, if down { STEP } else { -STEP }, height);
}
Screen::Diff(_) => {
let action = if down { Action::Down } else { Action::Up };
let mut cmds = Vec::new();
for _ in 0..3 {
cmds.extend(apply(state, action));
}
return cmds;
}
}
Vec::new()
}
MouseEventKind::Down(button) => click(state, x, y, button),
_ => Vec::new(),
}
}
#[must_use]
fn click(state: &mut State, x: u16, y: u16, button: MouseButton) -> Vec<Cmd> {
let inside = |r: Rect| r.contains((x, y).into());
match &state.overlay {
Some(Overlay::Search(sb)) => {
let lay = state.layout();
let chrome = state.chrome();
let crumbs: Vec<_> = chrome.crumbs.iter().map(|(c, _)| c.clone()).collect();
let right: Vec<_> = chrome.right.iter().map(|(t, _)| t.clone()).collect();
let field = chrome::header_layout(lay.header, &crumbs, &right).search;
let rows = state.suggestions(sb);
let panel = chrome::SearchPanel {
ctx: state.ctx(0),
rows: &rows.iter().map(|(r, _)| r.clone()).collect::<Vec<_>>(),
selected: sb.selected,
field,
};
let screen = Rect::new(0, 0, state.size.0, state.size.1);
if inside(field) {
return Vec::new();
}
if inside(panel.area(screen)) {
let row = panel.row_at(screen, y).and_then(|r| rows.get(r));
let Some((_, Some(pick))) = row.cloned() else {
return Vec::new();
};
state.overlay = None;
return choose(state, pick);
}
state.overlay = None;
return Vec::new();
}
Some(Overlay::Compose(_) | Overlay::Submit(_)) => return Vec::new(),
Some(_) => {
state.overlay = None;
return Vec::new();
}
None => {}
}
let lay = state.layout();
let chrome = state.chrome();
if inside(lay.header) {
let crumbs: Vec<_> = chrome.crumbs.iter().map(|(c, _)| c.clone()).collect();
let right: Vec<_> = chrome.right.iter().map(|(t, _)| t.clone()).collect();
let h = chrome::header_layout(lay.header, &crumbs, &right);
if inside(h.search) {
return state.open_search();
}
if inside(h.logo) {
return state.push(Route::Home);
}
let hit = |rects: &[Rect]| rects.iter().position(|r| inside(*r));
if let Some(&tab) = hit(&h.crumbs).and_then(|i| chrome.open_tabs.get(i)) {
return state.switch_to_tab(tab);
}
let crumb = hit(&h.crumbs).and_then(|i| chrome.crumbs.get(i)?.1.clone());
let right = hit(&h.right)
.and_then(|i| chrome.right.get(i))
.map(|(_, t)| t.clone());
return crumb
.or(right)
.map_or_else(Vec::new, |target| state.go(target.into()));
}
if let Some(tabs) = lay.tabs
&& inside(tabs)
{
let tab_list: Vec<_> = chrome.tabs.iter().map(|(t, _)| t.clone()).collect();
let rects = chrome::tab_layout(tabs, &tab_list, chrome.active);
return match rects.iter().position(|r| x >= r.x && x < r.right()) {
Some(i) => switch_tab(state, i + 1),
None => Vec::new(),
};
}
let area = state.page_area();
let height = state.page_height();
let Screen::Page(p) = state.screen_mut() else {
return Vec::new();
};
if !inside(area) {
return Vec::new();
}
p.fresh = false;
let hit = page::hit(&p.page, area, p.scroll, x, y);
let item = hit.line.and_then(|l| p.page.item_at(l));
if button == MouseButton::Right {
p.selected = item.or(p.selected);
open_menu(state);
return Vec::new();
}
if let Some(link) = hit.link.and_then(|l| p.page.target(l)) {
let link = link.clone();
p.selected = item.or(p.selected);
return state.follow(&link);
}
match item {
Some(i) if p.selected == Some(i) => {
let link = p.selected_link().cloned();
link.map_or_else(Vec::new, |l| state.follow(&l))
}
Some(i) => {
p.selected = Some(i);
reveal(p, i, height);
Vec::new()
}
None => Vec::new(),
}
}
impl State {
pub fn context_repo(&self) -> Option<&RepoId> {
match self.screen() {
Screen::Page(p) => p.route.repo(),
Screen::Diff(d) => Some(d.of.repo()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hint_labels_are_short_and_unique() {
assert_eq!(hint_labels(3), ["a", "s", "d"]);
let many = hint_labels(30);
assert_eq!(many.len(), 30);
assert_eq!(
&many[..4],
["aa", "as", "sa", "ss"],
"first letters spread out"
);
assert!(many.iter().all(|l| l.len() == 2));
let mut sorted = many;
sorted.dedup();
assert_eq!(sorted.len(), 30);
}
#[test]
fn list_states_rewrite_the_query() {
assert_eq!(
with_state("is:open label:bug", "closed"),
"is:closed label:bug"
);
assert_eq!(with_state("is:closed", "all"), "");
assert_eq!(with_state("label:bug", "open"), "is:open label:bug");
}
}