use super::listscroll::ListScroll;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, List, ListItem, ListState, Paragraph, Wrap};
use crate::i18n::Strings;
use crate::remote_flow::{RemoteFlow, RemoteKind, Step, WorkspaceGitFilter};
pub(crate) use crate::remote_flow::{filter_indices, spawn_workspace_redownload};
use super::app::{MouseHitTarget, MouseLayer, MouseScrollTarget, TuiApp};
use super::draw::*;
use super::editor::*;
use super::theme::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RemoteStage {
Connect,
Loading,
PickRef,
PickFile,
PickWorkspaceFilter,
Error,
}
pub(crate) struct RemoteWizard {
pub(crate) flow: RemoteFlow,
pub(crate) url: Editor,
pub(crate) token: Editor,
pub(crate) recent: Vec<String>,
pub(crate) field: u8,
pub(crate) recent_sel: Option<usize>,
pub(crate) filter: String,
pub(crate) sel: usize,
pub(crate) list_scroll: ListScroll,
}
impl RemoteWizard {
pub(crate) fn new(kind: RemoteKind, recent: Vec<String>) -> Self {
Self {
flow: RemoteFlow::new(kind),
url: Editor::blank(),
token: Editor::blank(),
recent,
field: 0,
recent_sel: None,
filter: String::new(),
sel: 0,
list_scroll: ListScroll::default(),
}
}
pub(crate) fn kind(&self) -> RemoteKind {
self.flow.kind
}
pub(crate) fn stage(&self) -> RemoteStage {
if self.flow.error().is_some() {
return RemoteStage::Error;
}
if self.flow.busy().is_some() {
return RemoteStage::Loading;
}
match self.flow.step() {
Step::Connect => RemoteStage::Connect,
Step::PickRef => RemoteStage::PickRef,
Step::PickFile => RemoteStage::PickFile,
Step::PickWorkspaceFilter | Step::WorkspaceStorage => RemoteStage::PickWorkspaceFilter,
}
}
pub(crate) fn sync_fields(&mut self) {
self.flow.url = self.url.text();
self.flow.token = self.token.text();
}
pub(crate) fn reset_list(&mut self) {
self.filter.clear();
self.sel = 0;
}
}
pub(crate) fn draw_filter_list(
f: &mut Frame,
s: &Strings,
title: &str,
filter: &str,
items: &[String],
sel: usize,
th: &Theme,
scroll: &ListScroll,
) -> usize {
let w = (f.area().width * 7 / 10).max(50);
let h = (f.area().height * 7 / 10).max(10);
let area = centered_rect(w, h, f.area());
f.render_widget(Clear, area);
let block = panel_hinted(title.to_string(), s.git_filter_hint, th);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(1), Constraint::Min(1), ])
.split(inner);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(s.git_filter_label.to_string(), Style::default().fg(th.dim)),
Span::styled(filter.to_string(), Style::default().fg(th.text)),
])),
rows[0],
);
let vis = filter_indices(items.iter().map(|s| s.as_str()), filter);
let list_items: Vec<ListItem> = vis
.iter()
.map(|&i| ListItem::new(Line::styled(items[i].clone(), Style::default().fg(th.text))))
.collect();
let list = List::new(list_items)
.highlight_style(
Style::default()
.bg(th.accent)
.fg(th.bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("\u{203a} ");
let sel = (!vis.is_empty()).then(|| sel.min(vis.len() - 1));
let offset = scroll.render(f, rows[1], list, sel, vis.len());
offset
}
pub(crate) fn draw_choice_popup(
f: &mut Frame,
title: &str,
items: &[&str],
sel: usize,
hint: &str,
th: &Theme,
) {
let content_w = items
.iter()
.map(|s| s.chars().count())
.max()
.unwrap_or(20)
.max(title.chars().count());
let w = (content_w as u16 + 6).clamp(30, f.area().width.max(1));
let h = (items.len() as u16 + 2).min(f.area().height.max(1));
let area = centered_rect(w, h, f.area());
f.render_widget(Clear, area);
let block = panel_hinted(title.to_string(), hint, th);
let inner = block.inner(area);
f.render_widget(block, area);
let list_items: Vec<ListItem> = items
.iter()
.map(|i| ListItem::new(Line::styled(i.to_string(), Style::default().fg(th.text))))
.collect();
let list = List::new(list_items)
.highlight_style(
Style::default()
.bg(th.accent)
.fg(th.bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("\u{203a} ");
let mut st = ListState::default();
st.select(Some(sel.min(items.len().saturating_sub(1))));
f.render_stateful_widget(list, inner, &mut st);
}
#[cfg(test)]
pub(crate) fn draw_remote_wizard(f: &mut Frame, w: &RemoteWizard, s: &Strings, th: &Theme) {
draw_remote_wizard_with_hits(f, w, s, th, None);
}
pub(crate) fn draw_remote_wizard_with_hits(
f: &mut Frame,
w: &RemoteWizard,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
if let Some(app) = app {
app.set_mouse_layer(MouseLayer::Overlay);
}
let title = match w.kind() {
RemoteKind::Collection => s.git_collection_menu,
RemoteKind::Environment => s.git_env_menu,
RemoteKind::Report => s.git_report_menu,
RemoteKind::Workspace => s.git_workspace_menu,
};
match w.stage() {
RemoteStage::Connect => {
let (field, recent_sel) = (w.field, w.recent_sel);
let fields = [
(s.git_url_label, s.git_url_hint, &w.url, false),
(s.git_token_label, s.git_token_hint, &w.token, true),
];
let label_w = label_column(fields.iter().map(|(l, _, _, _)| *l));
let recent_rows = w.recent.len().min(5) as u16;
let area = centered_rect(74, recent_rows + 4, f.area());
f.render_widget(Clear, area);
let hint = if recent_rows > 0 {
format!("{} \u{b7} {}", s.git_connect_hint, s.git_recent_hint)
} else {
s.git_connect_hint.to_string()
};
let block = panel_hinted(title.to_string(), &hint, th);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(1), Constraint::Length(recent_rows), Constraint::Length(1), ])
.split(inner);
for (i, (label, placeholder, ed, mask)) in fields.iter().enumerate() {
let row = if i == 0 { rows[0] } else { rows[2] };
let cols = Layout::horizontal([Constraint::Length(label_w), Constraint::Min(1)])
.split(row);
f.render_widget(
Paragraph::new(Span::styled(*label, Style::default().fg(th.accent))),
cols[0],
);
render_line_field_hinted(f, cols[1], ed, field == i as u8, *mask, placeholder, th);
if let Some(app) = app {
app.push_mouse_hit(
MouseLayer::Overlay,
row,
MouseHitTarget::RemoteWizardRow(i),
);
}
}
if recent_rows > 0 {
let items: Vec<ListItem> = w
.recent
.iter()
.take(5)
.enumerate()
.map(|(i, u)| {
let style = if recent_sel == Some(i) {
Style::default()
.bg(th.accent)
.fg(th.bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.dim)
};
ListItem::new(Line::styled(
format!("{:pad$}{u}", "", pad = label_w as usize),
style,
))
})
.collect();
f.render_widget(List::new(items), rows[1]);
if let Some(app) = app {
for i in 0..w.recent.len().min(5) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(rows[1].x, rows[1].y + i as u16, rows[1].width, 1),
MouseHitTarget::RemoteWizardRow(10 + i),
);
}
}
}
}
RemoteStage::Loading => {
let msg = w.flow.busy().map_or(s.git_loading_refs, |p| p.label(s));
let width = (msg
.len()
.max(s.git_loading_hint.len())
.max(title.chars().count()) as u16
+ 4)
.min(f.area().width);
let area = centered_rect(width, 3, f.area());
f.render_widget(Clear, area);
let block = panel_hinted(title.to_string(), s.git_loading_hint, th);
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(
Paragraph::new(Span::styled(
msg,
Style::default().fg(th.text).add_modifier(Modifier::BOLD),
)),
inner,
);
}
RemoteStage::PickRef => {
let labels: Vec<String> = w.flow.ref_choices(s).into_iter().map(|r| r.label).collect();
let first = draw_filter_list(
f,
s,
s.git_pick_ref_title,
&w.filter,
&labels,
w.sel,
th,
&w.list_scroll,
);
register_remote_filter_hits(f, app, &w.filter, &labels, first);
}
RemoteStage::PickFile => {
let files = w.flow.pickable_files();
let first = draw_filter_list(
f,
s,
s.git_pick_file_title,
&w.filter,
&files,
w.sel,
th,
&w.list_scroll,
);
register_remote_filter_hits(f, app, &w.filter, &files, first);
}
RemoteStage::PickWorkspaceFilter => {
let labels: Vec<&str> = WorkspaceGitFilter::ALL.iter().map(|f| f.label(s)).collect();
draw_choice_popup(
f,
s.git_pick_workspace_filter_title,
&labels,
w.sel,
s.git_workspace_filter_hint,
th,
);
if let Some(app) = app {
let content_w = labels
.iter()
.map(|s| s.chars().count())
.max()
.unwrap_or(20)
.max(s.git_pick_workspace_filter_title.chars().count());
let w = (content_w as u16 + 6).clamp(30, f.area().width.max(1));
let h = (labels.len() as u16 + 2).min(f.area().height.max(1));
let area = centered_rect(w, h, f.area());
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
for i in 0..labels.len().min(inner.height as usize) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(inner.x, inner.y + i as u16, inner.width, 1),
MouseHitTarget::RemoteWizardRow(i),
);
}
}
}
RemoteStage::Error => {
let e = w.flow.error().unwrap_or_default().to_string();
let width = (f.area().width * 6 / 10).max(40);
let area = centered_rect(width, 7, f.area());
f.render_widget(Clear, area);
let block = panel_hinted(title.to_string(), s.git_error_hint, th);
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(
Paragraph::new(e)
.style(Style::default().fg(th.err))
.wrap(Wrap { trim: true }),
inner,
);
}
}
fn register_remote_filter_hits(
f: &Frame,
app: Option<&TuiApp>,
filter: &str,
items: &[String],
first: usize,
) {
let Some(app) = app else {
return;
};
let w = (f.area().width * 7 / 10).max(50);
let h = (f.area().height * 7 / 10).max(10);
let area = centered_rect(w, h, f.area());
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
let rows = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner);
let vis = filter_indices(items.iter().map(|s| s.as_str()), filter);
if vis.is_empty() {
return;
}
let visible = rows[1].height as usize;
app.push_mouse_hit(
MouseLayer::Overlay,
rows[1],
MouseHitTarget::Scroll(MouseScrollTarget::RemoteWizard),
);
for row in first..vis.len().min(first + visible) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(
rows[1].x,
rows[1].y + (row - first) as u16,
rows[1].width,
1,
),
MouseHitTarget::RemoteWizardRow(row),
);
}
}
}