mirror of
https://seed.radicle.dev/rad:z39mP9rQAaGmERfUMPULfPUi473tY.git
synced 2026-11-02 01:40:33 +08:00
769 lines
26 KiB
Rust
769 lines
26 KiB
Rust
use std::any::Any;
|
|
use std::collections::HashMap;
|
|
use std::str::FromStr;
|
|
use std::vec;
|
|
|
|
use ratatui::widgets::TableState;
|
|
use tokio::sync::mpsc::UnboundedSender;
|
|
|
|
use termion::event::Key;
|
|
|
|
use ratatui::layout::{Constraint, Layout, Rect};
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::{Line, Span, Text};
|
|
|
|
use radicle::patch::{self, Status};
|
|
|
|
use radicle_tui as tui;
|
|
|
|
use tui::ui::items::{PatchItem, PatchItemFilter};
|
|
use tui::ui::span;
|
|
use tui::ui::widget::container::{
|
|
Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
|
|
};
|
|
use tui::ui::widget::input::{TextField, TextFieldProps, TextFieldState};
|
|
use tui::ui::widget::text::{Paragraph, ParagraphProps, ParagraphState};
|
|
use tui::ui::widget::TableUtils;
|
|
use tui::ui::widget::{self, BaseView};
|
|
use tui::ui::widget::{
|
|
Column, Properties, Shortcuts, ShortcutsProps, Table, TableProps, View, Widget,
|
|
};
|
|
use tui::Selection;
|
|
|
|
use crate::tui_patch::common::Mode;
|
|
use crate::tui_patch::common::PatchOperation;
|
|
|
|
use super::{Action, State};
|
|
|
|
type BoxedWidget = widget::BoxedWidget<State, Action>;
|
|
|
|
#[derive(Clone)]
|
|
pub struct BrowsePageProps<'a> {
|
|
mode: Mode,
|
|
patches: Vec<PatchItem>,
|
|
selected: Option<usize>,
|
|
search: String,
|
|
stats: HashMap<String, usize>,
|
|
columns: Vec<Column<'a>>,
|
|
cutoff: usize,
|
|
cutoff_after: usize,
|
|
focus: bool,
|
|
page_size: usize,
|
|
show_search: bool,
|
|
shortcuts: Vec<(&'a str, &'a str)>,
|
|
}
|
|
|
|
impl<'a> From<&State> for BrowsePageProps<'a> {
|
|
fn from(state: &State) -> Self {
|
|
let mut draft = 0;
|
|
let mut open = 0;
|
|
let mut archived = 0;
|
|
let mut merged = 0;
|
|
|
|
let patches = state.browser.patches();
|
|
|
|
for patch in &patches {
|
|
match patch.state {
|
|
patch::State::Draft => draft += 1,
|
|
patch::State::Open { conflicts: _ } => open += 1,
|
|
patch::State::Archived => archived += 1,
|
|
patch::State::Merged {
|
|
commit: _,
|
|
revision: _,
|
|
} => merged += 1,
|
|
}
|
|
}
|
|
|
|
let stats = HashMap::from([
|
|
("Draft".to_string(), draft),
|
|
("Open".to_string(), open),
|
|
("Archived".to_string(), archived),
|
|
("Merged".to_string(), merged),
|
|
]);
|
|
|
|
Self {
|
|
mode: state.mode.clone(),
|
|
patches,
|
|
search: state.browser.search.read(),
|
|
columns: [
|
|
Column::new(" ● ", Constraint::Length(3)),
|
|
Column::new("ID", Constraint::Length(8)),
|
|
Column::new("Title", Constraint::Fill(1)),
|
|
Column::new("Author", Constraint::Length(16)),
|
|
Column::new("", Constraint::Length(16)),
|
|
Column::new("Head", Constraint::Length(8)),
|
|
Column::new("+", Constraint::Length(6)),
|
|
Column::new("-", Constraint::Length(6)),
|
|
Column::new("Updated", Constraint::Length(16)),
|
|
]
|
|
.to_vec(),
|
|
cutoff: 150,
|
|
cutoff_after: 5,
|
|
focus: false,
|
|
stats,
|
|
page_size: state.browser.page_size,
|
|
show_search: state.browser.show_search,
|
|
selected: state.browser.selected,
|
|
shortcuts: match state.mode {
|
|
Mode::Id => vec![("enter", "select"), ("/", "search")],
|
|
Mode::Operation => vec![
|
|
("enter", "show"),
|
|
("c", "checkout"),
|
|
("d", "diff"),
|
|
("/", "search"),
|
|
("?", "help"),
|
|
],
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'a: 'static> Properties for BrowsePageProps<'a> {}
|
|
|
|
pub struct BrowsePage<'a> {
|
|
/// Internal base
|
|
base: BaseView<State, Action>,
|
|
/// Internal props
|
|
props: BrowsePageProps<'a>,
|
|
/// Notifications widget
|
|
patches: BoxedWidget,
|
|
/// Search widget
|
|
search: BoxedWidget,
|
|
/// Shortcut widget
|
|
shortcuts: BoxedWidget,
|
|
}
|
|
|
|
impl<'a: 'static> View for BrowsePage<'a> {
|
|
type Action = Action;
|
|
type State = State;
|
|
|
|
fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self {
|
|
let props = BrowsePageProps::from(state);
|
|
|
|
Self {
|
|
base: BaseView {
|
|
action_tx: action_tx.clone(),
|
|
on_update: None,
|
|
on_event: None,
|
|
},
|
|
props: props.clone(),
|
|
patches: Container::new(state, action_tx.clone())
|
|
.header(
|
|
Header::new(state, action_tx.clone())
|
|
.columns(props.columns.clone())
|
|
.cutoff(props.cutoff, props.cutoff_after)
|
|
.focus(props.focus)
|
|
.to_boxed(),
|
|
)
|
|
.content(Box::<Table<State, Action, PatchItem>>::new(
|
|
Table::new(state, action_tx.clone())
|
|
.on_event(|state, action_tx| {
|
|
state.downcast_ref::<TableState>().and_then(|state| {
|
|
action_tx
|
|
.send(Action::Select {
|
|
selected: state.selected(),
|
|
})
|
|
.ok()
|
|
});
|
|
})
|
|
.on_update(|state| {
|
|
let props = BrowsePageProps::from(state);
|
|
|
|
TableProps::default()
|
|
.columns(props.columns)
|
|
.items(state.browser.patches())
|
|
.footer(!state.browser.show_search)
|
|
.page_size(state.browser.page_size)
|
|
.cutoff(props.cutoff, props.cutoff_after)
|
|
.to_boxed()
|
|
}),
|
|
))
|
|
.footer(
|
|
Footer::new(state, action_tx.clone())
|
|
.on_update(|state| {
|
|
let props = BrowsePageProps::from(state);
|
|
|
|
FooterProps::default()
|
|
.columns(Self::build_footer(&props, props.selected))
|
|
.to_boxed()
|
|
})
|
|
.to_boxed(),
|
|
)
|
|
.to_boxed(),
|
|
search: Search::new(state, action_tx.clone()).to_boxed(),
|
|
shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(),
|
|
}
|
|
}
|
|
|
|
fn base_mut(&mut self) -> &mut BaseView<State, Action> {
|
|
&mut self.base
|
|
}
|
|
|
|
fn update(&mut self, state: &State) {
|
|
self.props = BrowsePageProps::from_callback(self.base.on_update, state)
|
|
.unwrap_or(BrowsePageProps::from(state));
|
|
|
|
self.patches.update(state);
|
|
self.search.update(state);
|
|
self.shortcuts.update(state);
|
|
}
|
|
|
|
fn handle_event(&mut self, key: Key) {
|
|
if self.props.show_search {
|
|
self.search.handle_event(key);
|
|
} else {
|
|
match key {
|
|
Key::Esc | Key::Ctrl('c') => {
|
|
let _ = self.base.action_tx.send(Action::Exit { selection: None });
|
|
}
|
|
Key::Char('?') => {
|
|
let _ = self.base.action_tx.send(Action::OpenHelp);
|
|
}
|
|
Key::Char('/') => {
|
|
let _ = self.base.action_tx.send(Action::OpenSearch);
|
|
}
|
|
Key::Char('\n') => {
|
|
let operation = match self.props.mode {
|
|
Mode::Operation => Some(PatchOperation::Show.to_string()),
|
|
Mode::Id => None,
|
|
};
|
|
|
|
self.props
|
|
.selected
|
|
.and_then(|selected| self.props.patches.get(selected))
|
|
.and_then(|patch| {
|
|
self.base
|
|
.action_tx
|
|
.send(Action::Exit {
|
|
selection: Some(Selection {
|
|
operation,
|
|
ids: vec![patch.id],
|
|
args: vec![],
|
|
}),
|
|
})
|
|
.ok()
|
|
});
|
|
}
|
|
Key::Char('c') => {
|
|
self.props
|
|
.selected
|
|
.and_then(|selected| self.props.patches.get(selected))
|
|
.and_then(|patch| {
|
|
self.base
|
|
.action_tx
|
|
.send(Action::Exit {
|
|
selection: Some(Selection {
|
|
operation: Some(PatchOperation::Checkout.to_string()),
|
|
ids: vec![patch.id],
|
|
args: vec![],
|
|
}),
|
|
})
|
|
.ok()
|
|
});
|
|
}
|
|
Key::Char('d') => {
|
|
self.props
|
|
.selected
|
|
.and_then(|selected| self.props.patches.get(selected))
|
|
.and_then(|patch| {
|
|
self.base
|
|
.action_tx
|
|
.send(Action::Exit {
|
|
selection: Some(Selection {
|
|
operation: Some(PatchOperation::Diff.to_string()),
|
|
ids: vec![patch.id],
|
|
args: vec![],
|
|
}),
|
|
})
|
|
.ok()
|
|
});
|
|
}
|
|
_ => {
|
|
self.patches.handle_event(key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'a> BrowsePage<'a> {
|
|
fn build_footer(props: &BrowsePageProps<'a>, selected: Option<usize>) -> Vec<Column<'a>> {
|
|
let filter = PatchItemFilter::from_str(&props.search).unwrap_or_default();
|
|
|
|
let search = Line::from(vec![
|
|
span::default(" Search ").cyan().dim().reversed(),
|
|
span::default(" "),
|
|
span::default(&props.search.to_string()).gray().dim(),
|
|
]);
|
|
|
|
let draft = Line::from(vec![
|
|
span::default(&props.stats.get("Draft").unwrap_or(&0).to_string()).dim(),
|
|
span::default(" Draft").dim(),
|
|
]);
|
|
|
|
let open = Line::from(vec![
|
|
span::positive(&props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
|
|
span::default(" Open").dim(),
|
|
]);
|
|
|
|
let merged = Line::from(vec![
|
|
span::default(&props.stats.get("Merged").unwrap_or(&0).to_string())
|
|
.magenta()
|
|
.dim(),
|
|
span::default(" Merged").dim(),
|
|
]);
|
|
|
|
let archived = Line::from(vec![
|
|
span::default(&props.stats.get("Archived").unwrap_or(&0).to_string())
|
|
.yellow()
|
|
.dim(),
|
|
span::default(" Archived").dim(),
|
|
]);
|
|
|
|
let sum = Line::from(vec![
|
|
span::default("Σ ").dim(),
|
|
span::default(&props.patches.len().to_string()).dim(),
|
|
]);
|
|
|
|
let progress = selected
|
|
.map(|selected| TableUtils::progress(selected, props.patches.len(), props.page_size))
|
|
.unwrap_or_default();
|
|
let progress = span::default(&format!("{}%", progress)).dim();
|
|
|
|
match filter.status() {
|
|
Some(state) => {
|
|
let block = match state {
|
|
Status::Draft => draft,
|
|
Status::Open => open,
|
|
Status::Merged => merged,
|
|
Status::Archived => archived,
|
|
};
|
|
|
|
vec![
|
|
Column::new(Text::from(search), Constraint::Fill(1)),
|
|
Column::new(
|
|
Text::from(block.clone()),
|
|
Constraint::Min(block.width() as u16),
|
|
),
|
|
Column::new(Text::from(progress), Constraint::Min(4)),
|
|
]
|
|
}
|
|
None => vec![
|
|
Column::new(Text::from(search), Constraint::Fill(1)),
|
|
Column::new(
|
|
Text::from(draft.clone()),
|
|
Constraint::Min(draft.width() as u16),
|
|
),
|
|
Column::new(
|
|
Text::from(open.clone()),
|
|
Constraint::Min(open.width() as u16),
|
|
),
|
|
Column::new(
|
|
Text::from(merged.clone()),
|
|
Constraint::Min(merged.width() as u16),
|
|
),
|
|
Column::new(
|
|
Text::from(archived.clone()),
|
|
Constraint::Min(archived.width() as u16),
|
|
),
|
|
Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
|
|
Column::new(Text::from(progress), Constraint::Min(4)),
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'a: 'static> Widget for BrowsePage<'a> {
|
|
fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<Box<dyn Any>>) {
|
|
let props = props
|
|
.and_then(BrowsePageProps::from_boxed_any)
|
|
.unwrap_or(self.props.clone());
|
|
|
|
let page_size = area.height.saturating_sub(6) as usize;
|
|
|
|
let [content_area, shortcuts_area] =
|
|
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(area);
|
|
|
|
if props.show_search {
|
|
let [table_area, search_area] =
|
|
Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(content_area);
|
|
|
|
self.patches.render(
|
|
frame,
|
|
table_area,
|
|
Some(
|
|
ContainerProps::default()
|
|
.hide_footer(props.show_search)
|
|
.to_boxed(),
|
|
),
|
|
);
|
|
self.search.render(frame, search_area, None);
|
|
} else {
|
|
self.patches.render(
|
|
frame,
|
|
content_area,
|
|
Some(
|
|
ContainerProps::default()
|
|
.hide_footer(props.show_search)
|
|
.to_boxed(),
|
|
),
|
|
);
|
|
}
|
|
|
|
self.shortcuts.render(
|
|
frame,
|
|
shortcuts_area,
|
|
Some(
|
|
ShortcutsProps::default()
|
|
.shortcuts(&props.shortcuts)
|
|
.to_boxed(),
|
|
),
|
|
);
|
|
|
|
if page_size != props.page_size {
|
|
let _ = self.base.action_tx.send(Action::BrowserPageSize(page_size));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct SearchProps {}
|
|
|
|
impl Properties for SearchProps {}
|
|
|
|
pub struct Search {
|
|
/// Internal base
|
|
base: BaseView<State, Action>,
|
|
/// Internal props
|
|
_props: SearchProps,
|
|
/// Search input field
|
|
input: BoxedWidget,
|
|
}
|
|
|
|
impl View for Search {
|
|
type Action = Action;
|
|
type State = State;
|
|
|
|
fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
|
|
where
|
|
Self: Sized,
|
|
{
|
|
let input = TextField::new(state, action_tx.clone())
|
|
.on_event(|state, action_tx| {
|
|
state.downcast_ref::<TextFieldState>().and_then(|state| {
|
|
action_tx
|
|
.send(Action::UpdateSearch {
|
|
value: state.text.clone().unwrap_or_default(),
|
|
})
|
|
.ok()
|
|
});
|
|
})
|
|
.on_update(|state| {
|
|
TextFieldProps::default()
|
|
.text(&state.browser.search.read().to_string())
|
|
.title("Search")
|
|
.inline(true)
|
|
.to_boxed()
|
|
})
|
|
.to_boxed();
|
|
Self {
|
|
base: BaseView {
|
|
action_tx: action_tx.clone(),
|
|
on_update: None,
|
|
on_event: None,
|
|
},
|
|
_props: SearchProps {},
|
|
input,
|
|
}
|
|
}
|
|
|
|
fn base_mut(&mut self) -> &mut BaseView<State, Action> {
|
|
&mut self.base
|
|
}
|
|
|
|
fn update(&mut self, state: &State) {
|
|
self.input.update(state);
|
|
}
|
|
|
|
fn handle_event(&mut self, key: termion::event::Key) {
|
|
match key {
|
|
Key::Esc => {
|
|
let _ = self.base.action_tx.send(Action::CloseSearch);
|
|
}
|
|
Key::Char('\n') => {
|
|
let _ = self.base.action_tx.send(Action::ApplySearch);
|
|
}
|
|
_ => {
|
|
self.input.handle_event(key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Widget for Search {
|
|
fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: Option<Box<dyn Any>>) {
|
|
let layout = Layout::horizontal(Constraint::from_mins([0]))
|
|
.horizontal_margin(1)
|
|
.split(area);
|
|
|
|
self.input.render(frame, layout[0], None);
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct HelpPageProps<'a> {
|
|
focus: bool,
|
|
page_size: usize,
|
|
help_progress: usize,
|
|
shortcuts: Vec<(&'a str, &'a str)>,
|
|
}
|
|
|
|
impl<'a> From<&State> for HelpPageProps<'a> {
|
|
fn from(state: &State) -> Self {
|
|
Self {
|
|
focus: false,
|
|
page_size: state.help.page_size,
|
|
help_progress: state.help.progress,
|
|
shortcuts: vec![("?", "close")],
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'a> Properties for HelpPageProps<'a> {}
|
|
|
|
pub struct HelpPage<'a> {
|
|
/// Internal base
|
|
base: BaseView<State, Action>,
|
|
/// Internal props
|
|
props: HelpPageProps<'a>,
|
|
/// Content widget
|
|
content: BoxedWidget,
|
|
/// Shortcut widget
|
|
shortcuts: BoxedWidget,
|
|
}
|
|
|
|
impl<'a: 'static> View for HelpPage<'a> {
|
|
type Action = Action;
|
|
type State = State;
|
|
|
|
fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
|
|
where
|
|
Self: Sized,
|
|
{
|
|
Self {
|
|
base: BaseView {
|
|
action_tx: action_tx.clone(),
|
|
on_update: None,
|
|
on_event: None,
|
|
},
|
|
props: HelpPageProps::from(state),
|
|
content: Container::new(state, action_tx.clone())
|
|
.header(
|
|
Header::new(state, action_tx.clone())
|
|
.on_update(|state| {
|
|
let props = HelpPageProps::from(state);
|
|
|
|
HeaderProps::default()
|
|
.columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
|
|
.focus(props.focus)
|
|
.to_boxed()
|
|
})
|
|
.to_boxed(),
|
|
)
|
|
.content(
|
|
Paragraph::new(state, action_tx.clone())
|
|
.on_update(|state| {
|
|
let props = HelpPageProps::from(state);
|
|
|
|
ParagraphProps::default()
|
|
.text(&help_text())
|
|
.page_size(props.page_size)
|
|
.focus(props.focus)
|
|
.to_boxed()
|
|
})
|
|
.on_event(|state, action_tx| {
|
|
state.downcast_ref::<ParagraphState>().and_then(|state| {
|
|
action_tx
|
|
.send(Action::ScrollHelp {
|
|
progress: state.progress,
|
|
})
|
|
.ok()
|
|
});
|
|
})
|
|
.to_boxed(),
|
|
)
|
|
.footer(
|
|
Footer::new(state, action_tx.clone())
|
|
.on_update(|state| {
|
|
let props = HelpPageProps::from(state);
|
|
|
|
FooterProps::default()
|
|
.columns(
|
|
[
|
|
Column::new(Text::raw(""), Constraint::Fill(1)),
|
|
Column::new(
|
|
span::default(&format!("{}%", props.help_progress))
|
|
.dim(),
|
|
Constraint::Min(4),
|
|
),
|
|
]
|
|
.to_vec(),
|
|
)
|
|
.focus(props.focus)
|
|
.to_boxed()
|
|
})
|
|
.to_boxed(),
|
|
)
|
|
.to_boxed(),
|
|
shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(),
|
|
}
|
|
}
|
|
|
|
fn base_mut(&mut self) -> &mut BaseView<State, Action> {
|
|
&mut self.base
|
|
}
|
|
|
|
fn update(&mut self, state: &State) {
|
|
self.props = HelpPageProps::from_callback(self.base.on_update, state)
|
|
.unwrap_or(HelpPageProps::from(state));
|
|
|
|
self.content.update(state);
|
|
}
|
|
|
|
fn handle_event(&mut self, key: termion::event::Key) {
|
|
match key {
|
|
Key::Esc | Key::Ctrl('c') => {
|
|
let _ = self.base.action_tx.send(Action::Exit { selection: None });
|
|
}
|
|
Key::Char('?') => {
|
|
let _ = self.base.action_tx.send(Action::LeavePage);
|
|
}
|
|
_ => {
|
|
self.content.handle_event(key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'a: 'static> Widget for HelpPage<'a> {
|
|
fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<Box<dyn Any>>) {
|
|
let props = props
|
|
.and_then(HelpPageProps::from_boxed_any)
|
|
.unwrap_or(self.props.clone());
|
|
|
|
let page_size = area.height.saturating_sub(6) as usize;
|
|
|
|
let [content_area, shortcuts_area] =
|
|
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(area);
|
|
|
|
self.content.render(frame, content_area, None);
|
|
self.shortcuts.render(
|
|
frame,
|
|
shortcuts_area,
|
|
Some(
|
|
ShortcutsProps::default()
|
|
.shortcuts(&props.shortcuts)
|
|
.to_boxed(),
|
|
),
|
|
);
|
|
|
|
if page_size != props.page_size {
|
|
let _ = self.base.action_tx.send(Action::HelpPageSize(page_size));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn help_text() -> Text<'static> {
|
|
Text::from(
|
|
[
|
|
Line::from(Span::raw("Generic keybindings").cyan()),
|
|
Line::raw(""),
|
|
Line::from(vec![
|
|
Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
|
|
Span::raw(" "),
|
|
Span::raw("move cursor one line up").gray().dim(),
|
|
]),
|
|
Line::from(vec![
|
|
Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
|
|
Span::raw(" "),
|
|
Span::raw("move cursor one line down").gray().dim(),
|
|
]),
|
|
Line::from(vec![
|
|
Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
|
|
Span::raw(" "),
|
|
Span::raw("move cursor one page up").gray().dim(),
|
|
]),
|
|
Line::from(vec![
|
|
Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
|
|
Span::raw(" "),
|
|
Span::raw("move cursor one page down").gray().dim(),
|
|
]),
|
|
Line::from(vec![
|
|
Span::raw(format!("{key:>10}", key = "Home")).gray(),
|
|
Span::raw(" "),
|
|
Span::raw("move cursor to the first line").gray().dim(),
|
|
]),
|
|
Line::from(vec![
|
|
Span::raw(format!("{key:>10}", key = "End")).gray(),
|
|
Span::raw(" "),
|
|
Span::raw("move cursor to the last line").gray().dim(),
|
|
]),
|
|
Line::raw(""),
|
|
Line::from(Span::raw("Specific keybindings").cyan()),
|
|
Line::raw(""),
|
|
Line::from(vec![
|
|
Span::raw(format!("{key:>10}", key = "enter")).gray(),
|
|
Span::raw(" "),
|
|
Span::raw("Select patch (if --mode id)").gray().dim(),
|
|
]),
|
|
Line::from(vec![
|
|
Span::raw(format!("{key:>10}", key = "enter")).gray(),
|
|
Span::raw(" "),
|
|
Span::raw("Show patch").gray().dim(),
|
|
]),
|
|
Line::from(vec![
|
|
Span::raw(format!("{key:>10}", key = "c")).gray(),
|
|
Span::raw(" "),
|
|
Span::raw("Checkout patch").gray().dim(),
|
|
]),
|
|
Line::from(vec![
|
|
Span::raw(format!("{key:>10}", key = "d")).gray(),
|
|
Span::raw(" "),
|
|
Span::raw("Show patch diff").gray().dim(),
|
|
]),
|
|
Line::from(vec![
|
|
Span::raw(format!("{key:>10}", key = "/")).gray(),
|
|
Span::raw(" "),
|
|
Span::raw("Search").gray().dim(),
|
|
]),
|
|
Line::from(vec![
|
|
Span::raw(format!("{key:>10}", key = "?")).gray(),
|
|
Span::raw(" "),
|
|
Span::raw("Show help").gray().dim(),
|
|
]),
|
|
Line::from(vec![
|
|
Span::raw(format!("{key:>10}", key = "Esc")).gray(),
|
|
Span::raw(" "),
|
|
Span::raw("Quit / cancel").gray().dim(),
|
|
]),
|
|
Line::raw(""),
|
|
Line::from(Span::raw("Searching").cyan()),
|
|
Line::raw(""),
|
|
Line::from(vec![
|
|
Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
|
|
Span::raw(" "),
|
|
Span::raw("is:<state> | is:authored | authors:[<did>, <did>] | <search>")
|
|
.gray()
|
|
.dim(),
|
|
]),
|
|
Line::from(vec![
|
|
Span::raw(format!("{key:>10}", key = "Example")).gray(),
|
|
Span::raw(" "),
|
|
Span::raw("is:open is:authored improve").gray().dim(),
|
|
]),
|
|
Line::raw(""),
|
|
Line::raw(""),
|
|
]
|
|
.to_vec(),
|
|
)
|
|
}
|