use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver};
use std::thread;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, List, ListItem, ListState, Paragraph, Wrap};
use crate::git_remote::{self, RefKind, RemoteRefs};
use crate::i18n::Strings;
use super::draw::*;
use super::editor::*;
use super::theme::*;
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum RemoteKind {
Collection,
Environment,
Workspace,
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub(crate) enum WorkspaceGitFilter {
HurlAndJson,
HurlOnly,
JsonOnly,
All,
}
impl WorkspaceGitFilter {
pub(crate) const ALL: [WorkspaceGitFilter; 4] = [
WorkspaceGitFilter::HurlAndJson,
WorkspaceGitFilter::HurlOnly,
WorkspaceGitFilter::JsonOnly,
WorkspaceGitFilter::All,
];
pub(crate) fn label(self, s: &Strings) -> &'static str {
match self {
WorkspaceGitFilter::HurlAndJson => s.git_ws_filter_hurl_json,
WorkspaceGitFilter::HurlOnly => s.git_ws_filter_hurl,
WorkspaceGitFilter::JsonOnly => s.git_ws_filter_json,
WorkspaceGitFilter::All => s.git_ws_filter_all,
}
}
pub(crate) fn matches(self, path: &str) -> bool {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str());
match self {
WorkspaceGitFilter::All => true,
WorkspaceGitFilter::HurlAndJson => {
matches!(ext, Some(e) if e.eq_ignore_ascii_case("hurl") || e.eq_ignore_ascii_case("json"))
}
WorkspaceGitFilter::HurlOnly => {
matches!(ext, Some(e) if e.eq_ignore_ascii_case("hurl"))
}
WorkspaceGitFilter::JsonOnly => {
matches!(ext, Some(e) if e.eq_ignore_ascii_case("json"))
}
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub(crate) struct WorkspaceGitOrigin {
pub(crate) repo_url: String,
pub(crate) commit_sha: String,
pub(crate) ref_kind: RefKind,
pub(crate) ref_name: String,
pub(crate) filter: WorkspaceGitFilter,
}
#[derive(Clone)]
pub(crate) struct RefChoice {
pub(crate) label: String,
pub(crate) gitref: String,
}
pub(crate) enum GitMsg {
Refs(Result<RemoteRefs, String>),
Files(Result<(Vec<String>, PathBuf, String), String>),
Content(Result<String, String>),
Workspace(Result<PathBuf, String>),
}
pub(crate) enum RemoteStage {
Connect {
field: u8,
recent_sel: Option<usize>,
},
Loading { phase: LoadPhase },
PickRef {
refs: Vec<RefChoice>,
filter: String,
sel: usize,
},
PickFile {
files: Vec<String>,
filter: String,
sel: usize,
},
PickWorkspaceFilter { sel: usize },
Error(String),
}
#[derive(Clone, Copy)]
pub(crate) enum LoadPhase {
Refs,
Files,
File,
WorkspaceFiles,
}
pub(crate) struct RemoteWizard {
pub(crate) kind: RemoteKind,
pub(crate) url: Editor,
pub(crate) token: Editor,
pub(crate) stage: RemoteStage,
pub(crate) rx: Option<Receiver<GitMsg>>,
pub(crate) repo: Option<PathBuf>,
pub(crate) selected_path: Option<String>,
pub(crate) recent: Vec<String>,
pub(crate) chosen_ref: Option<RefChoice>,
pub(crate) files: Vec<String>,
pub(crate) chosen_workspace_filter: Option<WorkspaceGitFilter>,
pub(crate) chosen_sha: Option<String>,
}
impl RemoteWizard {
pub(crate) fn new(kind: RemoteKind, recent: Vec<String>) -> Self {
Self {
kind,
url: Editor::blank(),
token: Editor::blank(),
stage: RemoteStage::Connect {
field: 0,
recent_sel: None,
},
rx: None,
repo: None,
selected_path: None,
recent,
chosen_ref: None,
files: Vec::new(),
chosen_workspace_filter: None,
chosen_sha: None,
}
}
pub(crate) fn token_opt(&self) -> Option<String> {
let t = self.token.text();
if t.trim().is_empty() { None } else { Some(t) }
}
}
pub(crate) fn filter_indices<'a>(items: impl Iterator<Item = &'a str>, filter: &str) -> Vec<usize> {
let f = filter.to_lowercase();
items
.enumerate()
.filter(|(_, s)| f.is_empty() || s.to_lowercase().contains(&f))
.map(|(i, _)| i)
.collect()
}
pub(crate) fn relevant_files(kind: RemoteKind, files: &[String]) -> Vec<String> {
let keep = |path: &String| -> bool {
let p = std::path::Path::new(path);
let ext = p.extension().and_then(|e| e.to_str());
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
match kind {
RemoteKind::Collection => {
matches!(ext, Some(e) if e.eq_ignore_ascii_case("hurl") || e.eq_ignore_ascii_case("json"))
}
RemoteKind::Environment => {
matches!(ext, Some(e) if e.eq_ignore_ascii_case("vars") || e.eq_ignore_ascii_case("env"))
|| name.eq_ignore_ascii_case(".env")
|| name.to_ascii_lowercase().starts_with(".env.")
}
RemoteKind::Workspace => true,
}
};
let filtered: Vec<String> = files.iter().filter(|p| keep(p)).cloned().collect();
if filtered.is_empty() {
files.to_vec()
} else {
filtered
}
}
pub(crate) fn build_ref_choices(refs: &RemoteRefs, s: &Strings) -> Vec<RefChoice> {
let mut out = Vec::with_capacity(refs.branches.len() + refs.tags.len());
for b in &refs.branches {
out.push(RefChoice {
label: format!("[{}] {b}", s.git_branches),
gitref: git_remote::branch_ref(b),
});
}
for t in &refs.tags {
out.push(RefChoice {
label: format!("[{}] {t}", s.git_tags),
gitref: git_remote::tag_ref(t),
});
}
out
}
pub(crate) fn spawn_git_refs(url: String, token: Option<String>) -> Receiver<GitMsg> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let _ = tx.send(GitMsg::Refs(git_remote::list_refs(&url, token.as_deref())));
});
rx
}
pub(crate) fn spawn_git_files(
url: String,
token: Option<String>,
gitref: String,
) -> Receiver<GitMsg> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let res = git_remote::list_files(&url, token.as_deref(), &gitref);
if let Err(mpsc::SendError(GitMsg::Files(Ok((_, dir, _))))) = tx.send(GitMsg::Files(res)) {
git_remote::cleanup(&dir);
}
});
rx
}
pub(crate) fn spawn_git_checkout(repo: PathBuf, path: String) -> Receiver<GitMsg> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let _ = tx.send(GitMsg::Content(git_remote::checkout_file(&repo, &path)));
});
rx
}
pub(crate) fn spawn_git_checkout_workspace(repo: PathBuf, paths: Vec<String>) -> Receiver<GitMsg> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let res = git_remote::checkout_files(&repo, &paths).map(|_| repo.clone());
if res.is_ok() {
git_remote::scrub_remote(&repo);
}
if let Err(mpsc::SendError(GitMsg::Workspace(Ok(dir)))) = tx.send(GitMsg::Workspace(res)) {
git_remote::cleanup(&dir);
}
});
rx
}
pub(crate) fn spawn_workspace_redownload(
origin: WorkspaceGitOrigin,
) -> Receiver<Result<PathBuf, String>> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let result = match git_remote::list_files(&origin.repo_url, None, &origin.commit_sha) {
Ok((files, repo, _sha)) => {
let matched: Vec<String> = files
.into_iter()
.filter(|p| origin.filter.matches(p))
.collect();
match git_remote::checkout_files(&repo, &matched) {
Ok(()) => Ok(repo),
Err(e) => {
git_remote::cleanup(&repo);
Err(e)
}
}
}
Err(e) => Err(e),
};
if let Err(mpsc::SendError(Ok(dir))) = tx.send(result) {
git_remote::cleanup(&dir);
}
});
rx
}
pub(crate) fn draw_filter_list(
f: &mut Frame,
s: &Strings,
title: &str,
filter: &str,
items: &[String],
sel: usize,
th: &Theme,
) {
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(title.to_string(), true, th);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(1), Constraint::Min(1), Constraint::Length(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 mut st = ListState::default();
if !vis.is_empty() {
st.select(Some(sel.min(vis.len() - 1)));
}
f.render_stateful_widget(list, rows[1], &mut st);
f.render_widget(
Paragraph::new(Line::styled(s.git_filter_hint, Style::default().fg(th.dim))),
rows[2],
);
}
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 + 3).min(f.area().height.max(1));
let area = centered_rect(w, h, f.area());
f.render_widget(Clear, area);
let block = panel(title.to_string(), true, th);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(inner);
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, rows[0], &mut st);
f.render_widget(
Paragraph::new(Line::styled(hint.to_string(), Style::default().fg(th.dim))),
rows[1],
);
}
pub(crate) fn draw_remote_wizard(f: &mut Frame, w: &RemoteWizard, s: &Strings, th: &Theme) {
let title = match w.kind {
RemoteKind::Collection => s.git_collection_menu,
RemoteKind::Environment => s.git_env_menu,
RemoteKind::Workspace => s.git_workspace_menu,
};
match &w.stage {
RemoteStage::Connect { field, recent_sel } => {
let recent_rows = w.recent.len().min(5) as u16;
let h = 10 + recent_rows;
let area = centered_rect(74, h, f.area());
f.render_widget(Clear, area);
let block = panel(title.to_string(), true, th);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(recent_rows), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), ])
.split(inner);
f.render_widget(
Paragraph::new(Span::styled(
s.git_url_label,
Style::default().fg(th.accent),
)),
rows[0],
);
render_line_field(f, rows[1], &w.url, *field == 0, false, th);
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(u.clone(), style))
})
.collect();
f.render_widget(List::new(items), rows[2]);
}
f.render_widget(
Paragraph::new(Span::styled(
s.git_token_label,
Style::default().fg(th.accent),
)),
rows[4],
);
render_line_field(f, rows[5], &w.token, *field == 1, true, th);
let hint = if recent_rows > 0 {
format!("{} · {}", s.git_connect_hint, s.git_recent_hint)
} else {
s.git_connect_hint.to_string()
};
f.render_widget(
Paragraph::new(Line::styled(hint, Style::default().fg(th.dim))),
rows[6],
);
}
RemoteStage::Loading { phase } => {
let msg = match phase {
LoadPhase::Refs => s.git_loading_refs,
LoadPhase::Files => s.git_loading_files,
LoadPhase::File => s.git_loading_file,
LoadPhase::WorkspaceFiles => s.git_loading_workspace_files,
};
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, 4, f.area());
f.render_widget(Clear, area);
let block = panel(title.to_string(), true, th);
let inner = block.inner(area);
f.render_widget(block, area);
let rows =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner);
f.render_widget(
Paragraph::new(Span::styled(
msg,
Style::default().fg(th.text).add_modifier(Modifier::BOLD),
)),
rows[0],
);
f.render_widget(
Paragraph::new(Line::styled(
s.git_loading_hint,
Style::default().fg(th.dim),
)),
rows[1],
);
}
RemoteStage::PickRef { refs, filter, sel } => {
let labels: Vec<String> = refs.iter().map(|r| r.label.clone()).collect();
draw_filter_list(f, s, s.git_pick_ref_title, filter, &labels, *sel, th);
}
RemoteStage::PickFile { files, filter, sel } => {
draw_filter_list(f, s, s.git_pick_file_title, filter, files, *sel, th);
}
RemoteStage::PickWorkspaceFilter { sel } => {
let labels: Vec<&str> = WorkspaceGitFilter::ALL.iter().map(|f| f.label(s)).collect();
draw_choice_popup(
f,
s.git_pick_workspace_filter_title,
&labels,
*sel,
s.git_workspace_filter_hint,
th,
);
}
RemoteStage::Error(e) => {
let width = (f.area().width * 6 / 10).max(40);
let area = centered_rect(width, 8, f.area());
f.render_widget(Clear, area);
let block = panel(title.to_string(), true, th);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(inner);
f.render_widget(
Paragraph::new(e.clone())
.style(Style::default().fg(th.err))
.wrap(Wrap { trim: true }),
rows[0],
);
f.render_widget(
Paragraph::new(Line::styled(s.git_error_hint, Style::default().fg(th.dim))),
rows[1],
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn files() -> Vec<String> {
[
"api/health.hurl",
"postman/orders.json",
"envs/dev.vars",
".env",
".env.dev-au",
"README.md",
"src/main.rs",
]
.iter()
.map(|s| s.to_string())
.collect()
}
#[test]
fn relevant_files_for_a_collection_keeps_only_hurl_and_json() {
let out = relevant_files(RemoteKind::Collection, &files());
assert_eq!(out, vec!["api/health.hurl", "postman/orders.json"]);
}
#[test]
fn relevant_files_for_an_environment_keeps_vars_and_dotenv_style_files() {
let out = relevant_files(RemoteKind::Environment, &files());
assert_eq!(out, vec!["envs/dev.vars", ".env", ".env.dev-au"]);
}
#[test]
fn relevant_files_falls_back_to_everything_when_nothing_matches() {
let noise: Vec<String> = ["a.md", "b.rs", "c.txt"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(relevant_files(RemoteKind::Collection, &noise), noise);
}
}