use std::path::{Path, PathBuf};
use std::time::Duration;
use eframe::egui;
use crate::git_remote::{self, GitOrigin, RefKind};
use crate::i18n::{Status, Strings};
use crate::remote_flow::{
FlowEvent, RefChoice, RemoteFlow, RemoteKind, Step, WorkspaceGitFilter, WorkspaceGitOrigin,
};
use crate::environment::Environment;
use crate::save_flow::{
SaveEvent, SaveFlow as CoreSaveFlow, SaveSource, SaveTargetKind, Step as CoreStep,
};
use super::app::GuiApp;
const REPAINT_WHILE_BUSY: Duration = Duration::from_millis(100);
#[derive(Default)]
pub struct RemoteUi {
flow: Option<Flow>,
}
enum Flow {
Load(LoadFlow),
Save(SaveFlow),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SaveTarget {
Collection(usize),
Workspace(usize),
Report,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum LoadTarget {
File,
Report,
Workspace,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LoadStep {
Connect,
PickRef,
PickFile,
PickWorkspaceFilter,
WorkspaceStorage,
}
struct LoadFlow {
flow: RemoteFlow,
target: LoadTarget,
ref_kind: RefKind,
selected_branch: usize,
selected_tag: usize,
filter: String,
show_all_files: bool,
selected_path: Option<String>,
ws_filter: WorkspaceGitFilter,
ws_root: Option<PathBuf>,
ws_name: String,
ws_origin: Option<WorkspaceGitOrigin>,
local_error: Option<String>,
visible_cache: std::cell::RefCell<Option<VisibleFiles>>,
}
struct VisibleFiles {
generation: u64,
filter: String,
show_all: bool,
out: std::rc::Rc<Vec<String>>,
}
impl LoadFlow {
fn new(target: LoadTarget) -> Self {
Self {
flow: RemoteFlow::new(match target {
LoadTarget::File => RemoteKind::Collection,
LoadTarget::Report => RemoteKind::Report,
LoadTarget::Workspace => RemoteKind::Workspace,
}),
target,
ref_kind: RefKind::Branch,
selected_branch: 0,
selected_tag: 0,
filter: String::new(),
show_all_files: false,
selected_path: None,
ws_filter: WorkspaceGitFilter::PaperboyFiles,
ws_root: None,
ws_name: String::new(),
ws_origin: None,
local_error: None,
visible_cache: std::cell::RefCell::new(None),
}
}
fn step(&self) -> LoadStep {
if self.ws_root.is_some() {
return LoadStep::WorkspaceStorage;
}
match self.flow.step() {
Step::Connect => LoadStep::Connect,
Step::PickRef => LoadStep::PickRef,
Step::PickFile => LoadStep::PickFile,
Step::PickWorkspaceFilter => LoadStep::PickWorkspaceFilter,
Step::WorkspaceStorage => LoadStep::WorkspaceStorage,
}
}
fn is_busy(&self) -> bool {
self.flow.is_busy()
}
fn busy_label(&self, s: &Strings) -> Option<&'static str> {
self.flow.busy().map(|phase| phase.label(s))
}
fn error(&self) -> Option<&str> {
self.local_error.as_deref().or_else(|| self.flow.error())
}
fn clear_errors(&mut self) {
self.local_error = None;
self.flow.clear_error();
}
fn selected_ref_name(&self) -> Option<&str> {
let refs = self.flow.refs();
match self.ref_kind {
RefKind::Branch => refs.branches.get(self.selected_branch),
RefKind::Tag => refs.tags.get(self.selected_tag),
}
.map(String::as_str)
}
fn selected_gitref(&self) -> Option<String> {
self.selected_ref_name().map(|name| match self.ref_kind {
RefKind::Branch => git_remote::branch_ref(name),
RefKind::Tag => git_remote::tag_ref(name),
})
}
fn visible_files(&self) -> std::rc::Rc<Vec<String>> {
let generation = self.flow.files_generation();
if let Some(hit) = self.visible_cache.borrow().as_ref()
&& hit.generation == generation
&& hit.filter == self.filter
&& hit.show_all == self.show_all_files
{
return hit.out.clone();
}
let filter = self.filter.to_lowercase();
let out: std::rc::Rc<Vec<String>> = std::rc::Rc::new(
self.flow
.all_files()
.iter()
.filter(|path| self.show_all_files || is_default_load_file(self.target, path))
.filter(|path| filter.is_empty() || path.to_lowercase().contains(&filter))
.cloned()
.collect(),
);
*self.visible_cache.borrow_mut() = Some(VisibleFiles {
generation,
filter: self.filter.clone(),
show_all: self.show_all_files,
out: out.clone(),
});
out
}
fn workspace_match_count(&self) -> usize {
self.flow
.all_files()
.iter()
.filter(|p| self.ws_filter.matches(p))
.count()
}
fn poll(&mut self, app: &mut GuiApp) -> bool {
match self.flow.poll() {
Some(FlowEvent::Content { text, origin, .. }) => {
self.finish_loaded_content(app, text, origin)
}
Some(FlowEvent::Workspace { root, name, origin }) => {
self.ws_root = Some(root);
self.ws_name = name;
self.ws_origin = origin;
false
}
None => false,
}
}
fn finish_workspace(&mut self, app: &mut GuiApp) -> bool {
let Some(root) = self.ws_root.take() else {
self.local_error = Some(app.strings.gui_git_err_browse_again.to_string());
return false;
};
let name = nonblank(&self.ws_name).unwrap_or_else(|| file_stem_from_url(&self.flow.url));
remember_git_url(&mut app.session, &self.flow.url);
app.session
.open_workspace_from_git(root, name, self.ws_origin.take());
true
}
fn finish_loaded_content(
&mut self,
app: &mut GuiApp,
content: String,
origin: Option<GitOrigin>,
) -> bool {
let Some(path) = self.selected_path.clone() else {
self.local_error = Some(app.strings.gui_git_err_no_file.to_string());
return false;
};
let Some(origin) = origin else {
self.local_error = Some(app.strings.gui_git_err_no_ref.to_string());
return false;
};
let name = name_from_repo_path(&path);
remember_git_url(&mut app.session, &self.flow.url);
if self.target == LoadTarget::Report {
return finish_loaded_report(app, name, content, origin);
}
if is_vars_file(&path) || crate::postman::postman_env_values(&content).is_some() {
if app
.session
.load_environment_text(name, &content, None, Some(origin))
.is_some()
{
return true;
}
self.local_error = Some(app.strings.gui_git_err_not_env.to_string());
return false;
}
if app.session.load_collection_text(name, &content, None) {
if let Some(col) = app.session.collections.last_mut() {
col.git_origin = Some(origin);
}
app.session.save();
true
} else {
self.local_error = Some(app.strings.gui_git_err_not_collection.to_string());
false
}
}
}
struct SaveFlow {
target: SaveTarget,
flow: Option<CoreSaveFlow>,
env: Option<Environment>,
blocked: bool,
selected_branch: usize,
last_step: SaveStep,
local_error: Option<String>,
}
impl SaveFlow {
fn new(target: SaveTarget) -> Self {
Self {
target,
flow: None,
env: None,
blocked: false,
selected_branch: 0,
last_step: SaveStep::Connect,
local_error: None,
}
}
fn ensure_initialized(&mut self, app: &GuiApp) {
if self.flow.is_some() || self.blocked {
return;
}
match self.target {
SaveTarget::Collection(ci) => {
let Some(col) = app.session.collections.get(ci) else {
return self.block(app.strings.gui_git_err_collection_missing);
};
let env = app.session.effective_env(ci);
self.flow = Some(CoreSaveFlow::for_collection(ci, col, env.as_ref()));
self.env = env;
}
SaveTarget::Workspace(ci) => {
let Some(col) = app.session.collections.get(ci) else {
return self.block(app.strings.gui_git_err_collection_missing);
};
let Some(origin) = col.workspace_git_origin.clone() else {
return self.block(app.strings.gui_git_err_ws_not_from_git);
};
self.flow = Some(CoreSaveFlow::for_workspace(ci, col, &origin));
}
SaveTarget::Report => {
let Some(editor) = app.report_editor.as_ref() else {
return self.block(app.strings.gui_git_err_collection_missing);
};
self.flow = Some(CoreSaveFlow::for_report(0, &editor.report));
}
}
}
fn block(&mut self, message: &str) {
self.blocked = true;
self.local_error = Some(message.to_string());
}
fn is_busy(&self) -> bool {
self.flow.as_ref().is_some_and(CoreSaveFlow::is_busy)
}
fn step(&self) -> SaveStep {
let Some(flow) = self.flow.as_ref() else {
return SaveStep::Connect;
};
match flow.step() {
CoreStep::Failed(_) => self.last_step,
CoreStep::Connect => SaveStep::Connect,
CoreStep::ChoosePaths => SaveStep::ChoosePaths,
CoreStep::ChooseTarget => SaveStep::ChooseTarget,
CoreStep::CommitMessage => SaveStep::CommitMessage,
CoreStep::Done => SaveStep::Done,
}
}
fn remember_step(&mut self) {
let step = self.step();
self.last_step = step;
}
fn error(&self) -> Option<&str> {
self.local_error
.as_deref()
.or_else(|| self.flow.as_ref().and_then(CoreSaveFlow::error))
}
fn clear_errors(&mut self) {
self.local_error = None;
if let Some(flow) = self.flow.as_mut() {
flow.clear_error();
}
}
fn busy_label(&self, s: &Strings) -> Option<&'static str> {
self.flow.as_ref()?.busy().map(|p| p.label(s))
}
fn poll(&mut self, app: &mut GuiApp) -> bool {
let Some(flow) = self.flow.as_mut() else {
return false;
};
match flow.poll(&app.strings) {
Some(SaveEvent::Pushed { commit_sha }) => self.finish_save(app, &commit_sha),
None => false,
}
}
fn finish_save(&mut self, app: &mut GuiApp, commit_sha: &str) -> bool {
let Some(flow) = self.flow.as_ref() else {
return false;
};
let repin = flow.target_kind.repins_origin();
match flow.source {
SaveSource::Collection { ci } => {
let Some(col) = app.session.collections.get_mut(ci) else {
self.local_error = Some(app.strings.gui_git_err_collection_closed.to_string());
return false;
};
if repin {
col.git_origin = Some(flow.pushed_origin());
}
col.mark_saved();
if repin
&& flow.include_env
&& let Some(env_id) = self.env.as_ref().map(|e| e.id)
&& let Some(env) = app.session.global_envs.iter_mut().find(|e| e.id == env_id)
{
env.git_origin = Some(GitOrigin {
path: flow.env_path.trim().to_string(),
..flow.pushed_origin()
});
}
}
SaveSource::Workspace { ci, filter, .. } => {
if repin && let Some(col) = app.session.collections.get_mut(ci) {
col.workspace_git_origin = Some(WorkspaceGitOrigin {
repo_url: flow.url.trim().to_string(),
commit_sha: commit_sha.to_string(),
ref_kind: RefKind::Branch,
ref_name: flow.target_name.trim().to_string(),
filter,
});
}
}
SaveSource::Report { .. } => {
if let Some(editor) = app.report_editor.as_mut() {
editor.report.dirty = false;
if repin {
editor.report.git_origin = Some(flow.pushed_origin());
}
}
}
}
remember_git_url(&mut app.session, &flow.url);
app.session.status = Some(Status::GitSaved);
app.session.save();
true
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SaveStep {
Connect,
ChoosePaths,
ChooseTarget,
CommitMessage,
Done,
}
#[derive(Default)]
enum UiAction {
#[default]
None,
Cancel,
Connect,
BackToConnect,
BrowseFiles,
BackToRefs,
LoadFile,
DownloadWorkspace,
KeepWorkspaceTemp,
SaveWorkspacePermanently,
SaveConnect,
SaveChoosePaths,
SaveChooseTarget,
Save,
}
#[derive(Clone, Copy)]
struct UiColors {
dim: egui::Color32,
err: egui::Color32,
text: egui::Color32,
ok: egui::Color32,
}
impl RemoteUi {
pub fn open_load(&mut self) {
self.flow = Some(Flow::Load(LoadFlow::new(LoadTarget::File)));
}
pub fn open_load_report(&mut self) {
self.flow = Some(Flow::Load(LoadFlow::new(LoadTarget::Report)));
}
pub fn open_load_workspace(&mut self) {
self.flow = Some(Flow::Load(LoadFlow::new(LoadTarget::Workspace)));
}
pub fn open_save_collection(&mut self, ci: usize) {
self.flow = Some(Flow::Save(SaveFlow::new(SaveTarget::Collection(ci))));
}
pub fn open_save_workspace(&mut self, ci: usize) {
self.flow = Some(Flow::Save(SaveFlow::new(SaveTarget::Workspace(ci))));
}
pub fn open_save_report(&mut self) {
self.flow = Some(Flow::Save(SaveFlow::new(SaveTarget::Report)));
}
pub fn is_open(&self) -> bool {
self.flow.is_some()
}
}
pub fn show(app: &mut GuiApp, ctx: &egui::Context) {
if !app.remote.is_open() {
return;
}
let Some(mut flow) = app.remote.flow.take() else {
return;
};
if let Flow::Save(save) = &mut flow {
save.ensure_initialized(app);
save.remember_step();
}
if flow.poll(app) {
return;
}
let colors = UiColors {
dim: app.theme.dim,
err: app.theme.err,
text: app.theme.text,
ok: app.theme.ok,
};
let title = flow.title(&app.strings);
let mut action = UiAction::None;
let strings = &app.strings;
let recent = app.session.recent_git_urls.clone();
let dismissed = super::widgets::dialog_resizable(ctx, title, [480.0, 460.0], |ui| {
action = draw_flow(ui, &mut flow, &recent, colors, strings);
})
.dismissed;
if dismissed {
action = UiAction::Cancel;
}
let mut close = false;
match action {
UiAction::None => {}
UiAction::Cancel => {
if let Flow::Load(load) = &mut flow
&& let Some(root) = load.ws_root.take()
{
git_remote::cleanup(&root);
}
close = true;
}
UiAction::Connect => {
if let Flow::Load(load) = &mut flow {
start_list_refs(load, &app.strings);
}
}
UiAction::SaveConnect => {
if let Flow::Save(save) = &mut flow {
save.clear_errors();
if let Some(f) = save.flow.as_mut() {
f.submit_connect(&app.strings);
}
}
}
UiAction::SaveChoosePaths => {
if let Flow::Save(save) = &mut flow {
save.clear_errors();
if let Some(f) = save.flow.as_mut() {
f.submit_paths(&app.strings);
}
}
}
UiAction::SaveChooseTarget => {
if let Flow::Save(save) = &mut flow {
save.clear_errors();
if let Some(f) = save.flow.as_mut() {
f.submit_target();
}
}
}
UiAction::BackToConnect => match &mut flow {
Flow::Load(load) => {
load.flow.back_to_connect();
load.local_error = None;
}
Flow::Save(save) => {
save.clear_errors();
if let Some(f) = save.flow.as_mut() {
f.back_to_connect();
}
}
},
UiAction::BrowseFiles => {
if let Flow::Load(load) = &mut flow {
start_list_files(load, &app.strings);
}
}
UiAction::BackToRefs => {
if let Flow::Load(load) = &mut flow {
load.flow.back_to_refs();
load.local_error = None;
}
}
UiAction::LoadFile => {
if let Flow::Load(load) = &mut flow {
start_checkout(load, &app.strings);
}
}
UiAction::DownloadWorkspace => {
if let Flow::Load(load) = &mut flow {
start_workspace_checkout(load, &app.strings);
}
}
UiAction::KeepWorkspaceTemp => {
if let Flow::Load(load) = &mut flow
&& load.finish_workspace(app)
{
return;
}
}
UiAction::SaveWorkspacePermanently => {
if let Flow::Load(load) = &mut flow {
ask_where_to_save_workspace(load, app);
}
}
UiAction::Save => {
if let Flow::Save(save) = &mut flow {
save.clear_errors();
start_save(save, app);
}
}
}
if close {
return;
}
if flow.is_busy() {
ctx.request_repaint_after(REPAINT_WHILE_BUSY);
}
app.remote.flow = Some(flow);
}
impl Flow {
fn title(&self, s: &Strings) -> &'static str {
match self {
Flow::Load(load) => match load.target {
LoadTarget::File => s.gui_git_load_title,
LoadTarget::Report => s.gui_git_load_report_title,
LoadTarget::Workspace => s.gui_git_load_workspace_title,
},
Flow::Save(save) => match save.target {
SaveTarget::Collection(_) => s.gui_git_save_collection_title,
SaveTarget::Workspace(_) => s.gui_git_save_workspace_title,
SaveTarget::Report => s.gui_git_save_report_title,
},
}
}
fn is_busy(&self) -> bool {
match self {
Flow::Load(load) => load.is_busy(),
Flow::Save(save) => save.is_busy(),
}
}
fn poll(&mut self, app: &mut GuiApp) -> bool {
match self {
Flow::Load(load) => load.poll(app),
Flow::Save(save) => save.poll(app),
}
}
}
fn draw_flow(
ui: &mut egui::Ui,
flow: &mut Flow,
recent: &[String],
colors: UiColors,
s: &Strings,
) -> UiAction {
match flow {
Flow::Load(load) => draw_load(ui, load, recent, colors, s),
Flow::Save(save) => draw_save(ui, save, recent, colors, s),
}
}
fn url_field(
ui: &mut egui::Ui,
url: &mut String,
recent: &[String],
busy: bool,
colors: UiColors,
s: &Strings,
) {
ui.horizontal(|ui| {
ui.colored_label(colors.dim, s.gui_git_repo_url);
if !recent.is_empty() {
ui.add_enabled_ui(!busy, |ui| {
ui.menu_button(s.gui_git_recent, |ui| {
for known in recent {
if ui.button(known).clicked() {
*url = known.clone();
ui.close();
}
}
});
});
}
});
ui.add_enabled(
!busy,
egui::TextEdit::singleline(url)
.hint_text(s.git_url_hint)
.desired_width(f32::INFINITY),
);
}
fn draw_busy_and_error(
ui: &mut egui::Ui,
busy_label: Option<&'static str>,
error: Option<&str>,
colors: UiColors,
) {
if let Some(label) = busy_label {
ui.horizontal(|ui| {
ui.spinner();
ui.colored_label(colors.dim, label);
});
ui.add_space(6.0);
}
if let Some(error) = error {
ui.colored_label(colors.err, error);
ui.add_space(6.0);
}
}
fn draw_load(
ui: &mut egui::Ui,
load: &mut LoadFlow,
recent: &[String],
colors: UiColors,
s: &Strings,
) -> UiAction {
let busy = load.is_busy();
let busy_label = load.busy_label(s);
let error = load.error().map(str::to_string);
draw_busy_and_error(ui, busy_label, error.as_deref(), colors);
match load.step() {
LoadStep::Connect => draw_load_connect(ui, load, recent, busy, colors, s),
LoadStep::PickRef => draw_load_pick_ref(ui, load, busy, colors, s),
LoadStep::PickFile => draw_load_pick_file(ui, load, busy, colors, s),
LoadStep::PickWorkspaceFilter => draw_load_workspace_filter(ui, load, busy, colors, s),
LoadStep::WorkspaceStorage => draw_load_workspace_storage(ui, load, busy, colors, s),
}
}
fn draw_load_workspace_filter(
ui: &mut egui::Ui,
load: &mut LoadFlow,
busy: bool,
colors: UiColors,
s: &Strings,
) -> UiAction {
let mut action = UiAction::None;
if let Some(sha) = load.flow.commit_sha() {
ui.colored_label(
colors.dim,
format!("{} {}", s.gui_git_fetched_at, short_sha(sha)),
);
}
ui.colored_label(colors.dim, s.gui_git_ws_pick_filter);
ui.add_space(4.0);
ui.add_enabled_ui(!busy, |ui| {
for choice in WorkspaceGitFilter::ALL {
ui.radio_value(&mut load.ws_filter, choice, choice.label(s));
}
});
let matched = load.workspace_match_count();
ui.add_space(4.0);
ui.colored_label(
colors.dim,
s.gui_git_ws_match_count
.replace("{n}", &matched.to_string())
.replace("{total}", &load.flow.all_files().len().to_string()),
);
if matched == 0 {
ui.colored_label(colors.err, s.gui_git_err_ws_no_matches);
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add_enabled(
!busy && matched > 0,
egui::Button::new(s.gui_git_ws_download),
)
.clicked()
{
action = UiAction::DownloadWorkspace;
}
if ui
.add_enabled(!busy, egui::Button::new(s.gui_git_back))
.clicked()
{
action = UiAction::BackToRefs;
}
if ui
.add_enabled(!busy, egui::Button::new(s.gui_cancel))
.clicked()
{
action = UiAction::Cancel;
}
});
action
}
fn draw_load_workspace_storage(
ui: &mut egui::Ui,
load: &mut LoadFlow,
busy: bool,
colors: UiColors,
s: &Strings,
) -> UiAction {
let mut action = UiAction::None;
ui.label(
egui::RichText::new(s.gui_git_ws_storage_title)
.strong()
.color(colors.text),
);
ui.add_space(4.0);
ui.colored_label(colors.dim, s.git_workspace_storage_q);
ui.add_space(6.0);
ui.colored_label(colors.dim, s.gui_git_ws_folder_name);
ui.add_enabled(
!busy,
egui::TextEdit::singleline(&mut load.ws_name).desired_width(f32::INFINITY),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add_enabled(!busy, egui::Button::new(s.git_workspace_storage_choose))
.clicked()
{
action = UiAction::SaveWorkspacePermanently;
}
if ui
.add_enabled(!busy, egui::Button::new(s.git_workspace_storage_temp))
.clicked()
{
action = UiAction::KeepWorkspaceTemp;
}
});
action
}
fn draw_load_connect(
ui: &mut egui::Ui,
load: &mut LoadFlow,
recent: &[String],
busy: bool,
colors: UiColors,
s: &Strings,
) -> UiAction {
let mut action = UiAction::None;
url_field(ui, &mut load.flow.url, recent, busy, colors, s);
ui.add_space(4.0);
ui.colored_label(colors.dim, s.gui_git_token);
ui.add_enabled(
!busy,
egui::TextEdit::singleline(&mut load.flow.token)
.hint_text(s.git_token_hint)
.desired_width(f32::INFINITY),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add_enabled(
!busy && !load.flow.url.trim().is_empty(),
egui::Button::new(s.gui_git_connect),
)
.clicked()
{
action = UiAction::Connect;
}
if ui
.add_enabled(!busy, egui::Button::new(s.gui_cancel))
.clicked()
{
action = UiAction::Cancel;
}
});
action
}
fn draw_load_pick_ref(
ui: &mut egui::Ui,
load: &mut LoadFlow,
busy: bool,
colors: UiColors,
s: &Strings,
) -> UiAction {
let mut action = UiAction::None;
ui.colored_label(colors.dim, s.gui_git_pick_ref);
ui.add_enabled_ui(!busy, |ui| {
ui.horizontal(|ui| {
ui.radio_value(
&mut load.ref_kind,
RefKind::Branch,
format!(
"{} ({})",
s.gui_git_branches,
load.flow.refs().branches.len()
),
);
ui.radio_value(
&mut load.ref_kind,
RefKind::Tag,
format!("{} ({})", s.gui_git_tags, load.flow.refs().tags.len()),
);
});
let choices = match load.ref_kind {
RefKind::Branch => &load.flow.refs().branches,
RefKind::Tag => &load.flow.refs().tags,
};
egui::ScrollArea::vertical()
.max_height(list_height(ui))
.show(ui, |ui| {
for (idx, name) in choices.iter().enumerate() {
let selected = match load.ref_kind {
RefKind::Branch => load.selected_branch == idx,
RefKind::Tag => load.selected_tag == idx,
};
if super::widgets::selectable(ui, selected, name.as_str()).clicked() {
match load.ref_kind {
RefKind::Branch => load.selected_branch = idx,
RefKind::Tag => load.selected_tag = idx,
}
}
}
});
});
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add_enabled(
!busy && load.selected_ref_name().is_some(),
egui::Button::new(s.gui_git_browse_files),
)
.clicked()
{
action = UiAction::BrowseFiles;
}
if ui
.add_enabled(!busy, egui::Button::new(s.gui_git_back))
.clicked()
{
action = UiAction::BackToConnect;
}
if ui
.add_enabled(!busy, egui::Button::new(s.gui_cancel))
.clicked()
{
action = UiAction::Cancel;
}
});
action
}
fn draw_load_pick_file(
ui: &mut egui::Ui,
load: &mut LoadFlow,
busy: bool,
colors: UiColors,
s: &Strings,
) -> UiAction {
let mut action = UiAction::None;
if let Some(sha) = load.flow.commit_sha() {
ui.colored_label(
colors.dim,
format!("{} {}", s.gui_git_fetched_at, short_sha(sha)),
);
}
ui.add_enabled_ui(!busy, |ui| {
ui.horizontal(|ui| {
ui.label(s.gui_git_filter);
ui.add(egui::TextEdit::singleline(&mut load.filter).desired_width(240.0));
ui.checkbox(&mut load.show_all_files, s.gui_git_show_all_files);
});
let visible = load.visible_files();
if visible.is_empty() {
ui.colored_label(colors.dim, s.gui_git_no_files);
} else {
egui::ScrollArea::vertical()
.max_height(list_height(ui))
.show(ui, |ui| {
for path in visible.iter() {
let selected = load.selected_path.as_deref() == Some(path.as_str());
if super::widgets::selectable(ui, selected, path.as_str()).clicked() {
load.selected_path = Some(path.clone());
}
}
});
}
});
if !load.flow.has_repo() {
ui.colored_label(colors.dim, s.gui_git_checkout_gone);
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add_enabled(
!busy && load.flow.has_repo() && load.selected_path.is_some(),
egui::Button::new(s.gui_git_load),
)
.clicked()
{
action = UiAction::LoadFile;
}
if ui
.add_enabled(!busy, egui::Button::new(s.gui_git_back))
.clicked()
{
action = UiAction::BackToRefs;
}
if ui
.add_enabled(!busy, egui::Button::new(s.gui_cancel))
.clicked()
{
action = UiAction::Cancel;
}
});
action
}
fn draw_save(
ui: &mut egui::Ui,
save: &mut SaveFlow,
recent: &[String],
colors: UiColors,
s: &Strings,
) -> UiAction {
let busy = save.is_busy();
let busy_label = save.busy_label(s);
let error = save.error().map(str::to_string);
draw_busy_and_error(ui, busy_label, error.as_deref(), colors);
if save.blocked {
return if ui.button(s.gui_cancel).clicked() {
UiAction::Cancel
} else {
UiAction::None
};
}
let step = save.step();
let Some(flow) = save.flow.as_mut() else {
return UiAction::None;
};
let mut action = UiAction::None;
match step {
SaveStep::Connect => {
url_field(ui, &mut flow.url, recent, busy, colors, s);
ui.add_space(4.0);
ui.colored_label(colors.dim, s.gui_git_token);
ui.add_enabled(
!busy,
egui::TextEdit::singleline(&mut flow.token)
.hint_text(s.git_token_hint)
.desired_width(f32::INFINITY),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add_enabled(
!busy && !flow.url.trim().is_empty(),
egui::Button::new(s.gui_git_connect),
)
.clicked()
{
action = UiAction::SaveConnect;
}
if ui
.add_enabled(!busy, egui::Button::new(s.gui_cancel))
.clicked()
{
action = UiAction::Cancel;
}
});
}
SaveStep::ChoosePaths => {
ui.colored_label(colors.dim, s.gui_git_path);
ui.add_enabled(
!busy,
egui::TextEdit::singleline(&mut flow.path).desired_width(f32::INFINITY),
);
if save.env.is_some() && flow.source.can_include_env() {
ui.add_space(6.0);
ui.add_enabled(
!busy,
egui::Checkbox::new(&mut flow.include_env, s.git_save_include_env_label),
);
if flow.include_env {
ui.add_space(4.0);
ui.colored_label(colors.dim, s.git_save_env_path_label);
ui.add_enabled(
!busy,
egui::TextEdit::singleline(&mut flow.env_path).desired_width(f32::INFINITY),
);
}
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add_enabled(
!busy && !flow.path.trim().is_empty(),
egui::Button::new(s.gui_git_next),
)
.clicked()
{
action = UiAction::SaveChoosePaths;
}
if ui
.add_enabled(!busy, egui::Button::new(s.gui_git_back))
.clicked()
{
action = UiAction::BackToConnect;
}
});
}
SaveStep::ChooseTarget => {
ui.horizontal(|ui| {
ui.selectable_value(
&mut flow.target_kind,
SaveTargetKind::Branch,
s.gui_git_branches,
);
ui.selectable_value(&mut flow.target_kind, SaveTargetKind::Tag, s.gui_git_tags);
});
ui.add_space(4.0);
ui.colored_label(
colors.dim,
if flow.target_kind == SaveTargetKind::Branch {
s.gui_git_branch
} else {
s.gui_git_tag
},
);
ui.add_enabled(
!busy,
egui::TextEdit::singleline(&mut flow.target_name).desired_width(f32::INFINITY),
);
let branches = flow.refs().branches.clone();
if flow.target_kind == SaveTargetKind::Branch && !branches.is_empty() {
ui.add_space(4.0);
egui::ComboBox::from_id_salt("git_save_existing_branch")
.selected_text(s.gui_git_existing_branch)
.show_ui(ui, |ui| {
for (i, branch) in branches.iter().enumerate() {
if ui
.selectable_label(save.selected_branch == i, branch)
.clicked()
{
save.selected_branch = i;
flow.target_name = branch.clone();
}
}
});
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add_enabled(
!busy && !flow.target_name.trim().is_empty(),
egui::Button::new(s.gui_git_next),
)
.clicked()
{
action = UiAction::SaveChooseTarget;
}
if ui
.add_enabled(!busy, egui::Button::new(s.gui_git_back))
.clicked()
{
action = UiAction::BackToConnect;
}
});
}
SaveStep::CommitMessage => {
ui.colored_label(colors.dim, s.gui_git_commit_message);
ui.add_enabled(
!busy,
egui::TextEdit::singleline(&mut flow.message).desired_width(f32::INFINITY),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add_enabled(
!busy && !flow.message.trim().is_empty(),
egui::Button::new(s.gui_git_save),
)
.clicked()
{
action = UiAction::Save;
}
if ui
.add_enabled(!busy, egui::Button::new(s.gui_cancel))
.clicked()
{
action = UiAction::Cancel;
}
});
}
SaveStep::Done => {
ui.colored_label(colors.ok, s.gui_git_saved);
ui.add_space(8.0);
if ui.button(s.gui_close).clicked() {
action = UiAction::Cancel;
}
}
}
action
}
fn start_list_refs(load: &mut LoadFlow, s: &Strings) {
if nonblank(load.flow.url.trim()).is_none() {
load.local_error = Some(s.gui_git_err_url_required.to_string());
return;
}
load.clear_errors();
load.flow.connect();
}
fn start_list_files(load: &mut LoadFlow, s: &Strings) {
let (Some(name), Some(gitref)) = (load.selected_ref_name(), load.selected_gitref()) else {
load.local_error = Some(s.gui_git_err_pick_ref_first.to_string());
return;
};
let choice = RefChoice {
label: name.to_string(),
gitref,
};
load.clear_errors();
load.filter.clear();
load.show_all_files = false;
load.selected_path = None;
load.flow.choose_ref(choice);
}
fn start_checkout(load: &mut LoadFlow, s: &Strings) {
let Some(path) = load.selected_path.clone() else {
load.local_error = Some(s.gui_git_err_pick_file.to_string());
return;
};
load.clear_errors();
load.flow.choose_file(path);
}
fn start_save(save: &mut SaveFlow, app: &mut GuiApp) {
let Some(flow) = save.flow.as_ref() else {
return;
};
let payload = match &flow.source {
SaveSource::Workspace { root, .. } => CoreSaveFlow::workspace_payload(root, &app.strings),
SaveSource::Collection { ci } => match app.session.collections.get(*ci) {
Some(col) => flow.collection_payload(col, save.env.as_ref(), &app.strings),
None => Err(app.strings.git_save_source_gone.to_string()),
},
SaveSource::Report { .. } => match app.report_editor.as_ref() {
Some(editor) => Ok(flow.report_payload(&editor.report)),
None => Err(app.strings.git_save_source_gone.to_string()),
},
};
if let Some(flow) = save.flow.as_mut() {
flow.submit_message(payload);
}
}
fn start_workspace_checkout(load: &mut LoadFlow, s: &Strings) {
if load.workspace_match_count() == 0 {
load.local_error = Some(s.gui_git_err_ws_no_matches.to_string());
return;
}
load.clear_errors();
let filter = load.ws_filter;
load.flow.choose_workspace_filter(filter);
}
fn ask_where_to_save_workspace(load: &mut LoadFlow, app: &mut GuiApp) {
if load.ws_root.is_none() {
load.local_error = Some(app.strings.gui_git_err_browse_again.to_string());
return;
}
if nonblank(&load.ws_name).is_none() {
load.local_error = Some(app.strings.gui_git_err_ws_name_required.to_string());
return;
}
let title = app.strings.git_workspace_storage_choose;
let dir = app.session.last_browse_dir.clone();
app.request_pick(
super::filepick::PickKind::Folder,
title,
dir.as_deref(),
super::menu::PickAction::GitWorkspaceDir,
);
}
pub(super) fn apply_picked_workspace_dir(app: &mut GuiApp, picked: Option<std::path::PathBuf>) {
let Some(parent) = picked else {
return; };
let Some(mut flow) = app.remote.flow.take() else {
return; };
if let Flow::Load(load) = &mut flow
&& save_workspace_into(load, app, parent)
&& load.finish_workspace(app)
{
return; }
app.remote.flow = Some(flow);
}
fn save_workspace_into(load: &mut LoadFlow, app: &mut GuiApp, parent: std::path::PathBuf) -> bool {
let Some(source) = load.ws_root.clone() else {
load.local_error = Some(app.strings.gui_git_err_browse_again.to_string());
return false;
};
let Some(name) = nonblank(&load.ws_name) else {
load.local_error = Some(app.strings.gui_git_err_ws_name_required.to_string());
return false;
};
let dest = parent.join(&name);
if dest.exists() {
load.local_error = Some(app.strings.gui_git_err_ws_exists.to_string());
return false;
}
if let Err(e) = crate::workspace::copy_dir_all(&source, &dest) {
load.local_error = Some(e.to_string());
return false;
}
git_remote::cleanup(&source);
app.session.last_browse_dir = Some(parent);
app.session.status = Some(Status::WorkspaceSaved);
load.ws_root = Some(dest);
true
}
fn nonblank(s: &str) -> Option<String> {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn is_default_load_file(target: LoadTarget, path: &str) -> bool {
let ext = Path::new(path).extension().and_then(|e| e.to_str());
match target {
LoadTarget::Report => matches!(ext, Some(e) if e.eq_ignore_ascii_case("trail")),
_ => matches!(ext, Some(e) if e.eq_ignore_ascii_case("hurl")
|| e.eq_ignore_ascii_case("vars")
|| e.eq_ignore_ascii_case("json")),
}
}
fn is_vars_file(path: &str) -> bool {
let ext = Path::new(path).extension().and_then(|e| e.to_str());
matches!(ext, Some(e) if e.eq_ignore_ascii_case("vars"))
}
fn name_from_repo_path(path: &str) -> String {
Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.filter(|s| !s.is_empty())
.unwrap_or(path)
.to_string()
}
fn file_stem_from_url(url: &str) -> String {
let trimmed = url.trim().trim_end_matches('/');
let last = trimmed
.rsplit(['/', ':'])
.find(|seg| !seg.is_empty())
.unwrap_or(trimmed);
let stem = last.strip_suffix(".git").unwrap_or(last).trim();
if stem.is_empty() {
"workspace".to_string()
} else {
stem.to_string()
}
}
fn finish_loaded_report(
app: &mut GuiApp,
fallback_name: String,
content: String,
origin: GitOrigin,
) -> bool {
let mut report = crate::report::Report::from_text(fallback_name, content);
report.git_origin = Some(origin.clone());
app.session
.reports
.push(crate::persistence::PersistedReport {
name: report.name.clone(),
text: report.text.clone(),
path: None,
git_origin: Some(origin),
workspace_root: None,
embedded_active: true,
});
let idx = app.session.reports.len() - 1;
app.open_report_editor(
crate::gui::report_editor::ReportOrigin::Session(idx),
report,
);
app.focus = super::Focus::Main;
app.session.save();
true
}
fn list_height(ui: &egui::Ui) -> f32 {
(ui.available_height() - 64.0).max(120.0)
}
fn short_sha(sha: &str) -> &str {
sha.get(..12).unwrap_or(sha)
}
fn remember_git_url(session: &mut crate::session::Session, url: &str) {
let Some(url) = nonblank(url) else {
return;
};
session.recent_git_urls.retain(|known| known != &url);
session.recent_git_urls.insert(0, url);
session.recent_git_urls.truncate(10);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collection::Collection;
use crate::hurl::HurlEntry;
use crate::i18n::Language;
use crate::remote_flow::Step;
use crate::session::Session;
fn git(args: &[&str], dir: &std::path::Path) {
let out = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.output()
.expect("git is on PATH");
assert!(out.status.success(), "git {args:?}: {out:?}");
}
fn seed_bare_repo() -> (String, std::path::PathBuf) {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = std::env::temp_dir().join(format!(
"paperboy-gui-remote-test-{}-{nanos}",
std::process::id()
));
let bare = base.join("bare.git");
let work = base.join("work");
std::fs::create_dir_all(&bare).unwrap();
std::fs::create_dir_all(&work).unwrap();
git(&["init", "--bare", "-q", "."], &bare);
git(&["init", "-q"], &work);
git(&["checkout", "-q", "-b", "main"], &work);
git(&["config", "user.name", "Seed"], &work);
git(&["config", "user.email", "seed@test"], &work);
std::fs::write(work.join("api.hurl"), "GET https://example.com/a\n").unwrap();
std::fs::write(work.join("legacy.json"), "{}").unwrap();
std::fs::write(work.join("dev.vars"), "HOST=example.com\n").unwrap();
std::fs::write(work.join("big.bin"), "x".repeat(512)).unwrap();
std::fs::write(
work.join("nightly.trail"),
"# name: Nightly\nREQUEST Health\n",
)
.unwrap();
git(&["add", "-A"], &work);
git(&["commit", "-q", "-m", "seed"], &work);
git(&["remote", "add", "origin", bare.to_str().unwrap()], &work);
git(&["push", "-q", "origin", "main"], &work);
(bare.to_str().unwrap().to_string(), base)
}
fn main_branch_index(load: &LoadFlow) -> usize {
load.flow
.refs()
.branches
.iter()
.position(|b| b == "main")
.expect("the seeded repo has a main branch")
}
fn pump_until_past(load: &mut LoadFlow, app: &mut GuiApp, step: LoadStep) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
if load.poll(app) {
return true;
}
if load.step() != step || load.error().is_some() {
break;
}
assert!(
std::time::Instant::now() < deadline,
"stuck on {step:?} (error: {:?})",
load.error()
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_eq!(load.error(), None, "flow failed on {step:?}");
false
}
fn seeded(target: LoadTarget, files: &[&str]) -> LoadFlow {
let mut load = LoadFlow::new(target);
load.flow = RemoteFlow::seed(
load.flow.kind,
"https://example.test/repo.git",
if target == LoadTarget::Workspace {
Step::PickWorkspaceFilter
} else {
Step::PickFile
},
files.iter().map(|f| f.to_string()).collect(),
Some(std::env::temp_dir().join("pb-gui-remote-test")),
);
load
}
#[test]
fn a_file_load_offers_collections_and_environments_together() {
let load = seeded(
LoadTarget::File,
&["api.hurl", "envs/dev.vars", "postman.json", "README.md"],
);
let visible = load.visible_files();
assert!(visible.contains(&"api.hurl".to_string()));
assert!(visible.contains(&"envs/dev.vars".to_string()));
assert!(visible.contains(&"postman.json".to_string()));
assert!(
!visible.contains(&"README.md".to_string()),
"unrelated files are hidden until 'show all' is ticked"
);
}
#[test]
fn showing_all_files_reveals_everything_the_repo_has() {
let mut load = seeded(LoadTarget::File, &["notes.md", "script.sh"]);
assert!(load.visible_files().is_empty());
load.show_all_files = true;
assert_eq!(load.visible_files().len(), 2);
}
#[test]
fn the_file_list_is_reused_until_something_it_depends_on_changes() {
let mut load = seeded(LoadTarget::File, &["api/Health.hurl", "api/orders.hurl"]);
let first = load.visible_files();
assert!(
std::rc::Rc::ptr_eq(&first, &load.visible_files()),
"a second frame is served the list already in hand"
);
load.filter = "health".to_string();
let filtered = load.visible_files();
assert!(
!std::rc::Rc::ptr_eq(&first, &filtered),
"typing in the filter box recomputes it"
);
assert_eq!(*filtered, vec!["api/Health.hurl"]);
load.show_all_files = true;
assert!(
!std::rc::Rc::ptr_eq(&filtered, &load.visible_files()),
"so does the show-all checkbox"
);
load.filter.clear();
load.show_all_files = false;
let before = load.visible_files();
load.flow = RemoteFlow::seed(
load.flow.kind,
"https://example.test/other.git",
Step::PickFile,
vec!["other/a.hurl".to_string(), "other/b.hurl".to_string()],
Some(std::env::temp_dir().join("pb-gui-remote-test")),
);
assert_ne!(*load.visible_files(), *before, "a fetch invalidates it");
}
#[test]
fn the_file_filter_is_case_insensitive() {
let mut load = seeded(LoadTarget::File, &["api/Health.hurl", "api/orders.hurl"]);
load.filter = "HEALTH".to_string();
assert_eq!(*load.visible_files(), vec!["api/Health.hurl"]);
}
#[test]
fn the_step_shown_follows_the_shared_flow() {
let mut load = LoadFlow::new(LoadTarget::File);
assert_eq!(load.step(), LoadStep::Connect);
load.flow.seed_refs(&["main"], &["v1"]);
assert_eq!(load.step(), LoadStep::PickRef);
}
#[test]
fn a_workspace_load_asks_for_a_type_filter_rather_than_a_file() {
let load = seeded(LoadTarget::Workspace, &["a.hurl", "b.json", "big/blob.bin"]);
assert_eq!(load.step(), LoadStep::PickWorkspaceFilter);
assert_eq!(load.workspace_match_count(), 2, "the .bin is left behind");
}
#[test]
fn a_filter_matching_nothing_is_reported_instead_of_downloading_nothing() {
let s = Strings::for_language(&Language::English);
let mut load = seeded(LoadTarget::Workspace, &["big/blob.bin", "readme.md"]);
start_workspace_checkout(&mut load, &s);
assert_eq!(load.error(), Some(s.gui_git_err_ws_no_matches));
assert!(!load.is_busy(), "nothing was fetched");
}
#[test]
fn a_used_repository_is_remembered_for_both_front_ends() {
let mut session = Session::default();
remember_git_url(&mut session, "https://example.test/a.git");
remember_git_url(&mut session, "https://example.test/b.git");
remember_git_url(&mut session, " https://example.test/a.git ");
assert_eq!(
session.recent_git_urls,
vec![
"https://example.test/a.git".to_string(),
"https://example.test/b.git".to_string()
],
"revisiting a repo moves it to the front rather than duplicating it"
);
remember_git_url(&mut session, " ");
assert_eq!(
session.recent_git_urls.len(),
2,
"a blank URL is not a repo"
);
for i in 0..12 {
remember_git_url(&mut session, &format!("https://example.test/{i}.git"));
}
assert_eq!(
session.recent_git_urls.len(),
10,
"the list is capped the same way the terminal caps it"
);
}
#[test]
fn connecting_without_a_url_is_reported() {
let s = Strings::for_language(&Language::English);
let mut load = LoadFlow::new(LoadTarget::File);
start_list_refs(&mut load, &s);
assert_eq!(load.error(), Some(s.gui_git_err_url_required));
assert_eq!(load.step(), LoadStep::Connect);
}
#[test]
fn an_error_from_either_source_is_shown() {
let mut load = LoadFlow::new(LoadTarget::File);
assert_eq!(load.error(), None);
load.flow.fail("remote hung up".into());
assert_eq!(load.error(), Some("remote hung up"));
load.clear_errors();
assert_eq!(load.error(), None);
}
#[test]
fn going_back_to_the_refs_keeps_them() {
let mut load = seeded(LoadTarget::File, &["a.hurl"]);
load.flow.seed_refs(&["main", "dev"], &[]);
load.flow.back_to_refs();
assert_eq!(load.step(), LoadStep::PickRef);
assert_eq!(load.flow.refs().branches.len(), 2);
assert!(!load.flow.has_repo(), "the checkout is released");
}
#[test]
fn the_selected_ref_becomes_a_fully_qualified_git_ref() {
let mut load = LoadFlow::new(LoadTarget::File);
load.flow.seed_refs(&["main"], &["v1.2"]);
assert_eq!(load.selected_gitref().as_deref(), Some("refs/heads/main"));
load.ref_kind = RefKind::Tag;
assert_eq!(load.selected_gitref().as_deref(), Some("refs/tags/v1.2"));
}
#[test]
fn a_file_can_be_loaded_from_a_real_repository_end_to_end() {
let (repo_url, base) = seed_bare_repo();
let mut app = GuiApp::for_test(Session::default());
let s = Strings::for_language(&Language::English);
let mut load = LoadFlow::new(LoadTarget::File);
load.flow.url = repo_url.clone();
start_list_refs(&mut load, &s);
pump_until_past(&mut load, &mut app, LoadStep::Connect);
assert_eq!(load.step(), LoadStep::PickRef);
assert!(load.flow.refs().branches.iter().any(|b| b == "main"));
load.selected_branch = main_branch_index(&load);
start_list_files(&mut load, &s);
pump_until_past(&mut load, &mut app, LoadStep::PickRef);
assert_eq!(load.step(), LoadStep::PickFile);
assert!(load.visible_files().contains(&"api.hurl".to_string()));
assert!(
!load.visible_files().contains(&"big.bin".to_string()),
"unrelated files stay hidden"
);
load.selected_path = Some("api.hurl".to_string());
start_checkout(&mut load, &s);
assert!(
pump_until_past(&mut load, &mut app, LoadStep::PickFile),
"the wizard closes itself once the file is loaded"
);
assert!(
!app.session.collections.is_empty(),
"the fetched collection reached the session"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn a_report_can_be_loaded_from_a_real_repository_end_to_end() {
let (repo_url, base) = seed_bare_repo();
let mut app = GuiApp::for_test(Session::default());
let s = Strings::for_language(&Language::English);
let mut load = LoadFlow::new(LoadTarget::Report);
load.flow.url = repo_url.clone();
start_list_refs(&mut load, &s);
pump_until_past(&mut load, &mut app, LoadStep::Connect);
load.selected_branch = main_branch_index(&load);
start_list_files(&mut load, &s);
pump_until_past(&mut load, &mut app, LoadStep::PickRef);
let offered = load.visible_files();
assert!(offered.contains(&"nightly.trail".to_string()));
assert!(
!offered.contains(&"api.hurl".to_string()),
"a report load offers reports, not collections"
);
load.selected_path = Some("nightly.trail".to_string());
start_checkout(&mut load, &s);
assert!(
pump_until_past(&mut load, &mut app, LoadStep::PickFile),
"the wizard closes itself once the report is loaded"
);
let editor = app.report_editor.as_ref().expect("the report tab opened");
assert_eq!(editor.report.name, "Nightly", "the header names the tab");
assert!(
editor.report.git_origin.is_some(),
"the report remembers where it came from, so it can be pushed back"
);
assert_eq!(
app.session.reports.len(),
1,
"it is carried in the session, so it survives a restart"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn a_workspace_can_be_downloaded_from_a_real_repository_end_to_end() {
let (repo_url, base) = seed_bare_repo();
let mut app = GuiApp::for_test(Session::default());
let s = Strings::for_language(&Language::English);
let mut load = LoadFlow::new(LoadTarget::Workspace);
load.flow.url = repo_url.clone();
start_list_refs(&mut load, &s);
pump_until_past(&mut load, &mut app, LoadStep::Connect);
load.selected_branch = main_branch_index(&load);
start_list_files(&mut load, &s);
pump_until_past(&mut load, &mut app, LoadStep::PickRef);
assert_eq!(load.step(), LoadStep::PickWorkspaceFilter);
load.ws_filter = WorkspaceGitFilter::PaperboyFiles;
start_workspace_checkout(&mut load, &s);
pump_until_past(&mut load, &mut app, LoadStep::PickWorkspaceFilter);
assert_eq!(
load.step(),
LoadStep::WorkspaceStorage,
"the download pauses to ask where it should live"
);
let root = load.ws_root.clone().expect("a folder was downloaded");
assert!(root.join("api.hurl").is_file());
assert!(root.join("legacy.json").is_file());
assert!(
root.join("dev.vars").is_file() && root.join("nightly.trail").is_file(),
"a workspace arrives with the environments and reports its requests need"
);
assert!(
!root.join("big.bin").exists(),
"the chosen filter left behind what PaperBoy cannot open"
);
assert!(load.finish_workspace(&mut app));
assert!(
app.session
.collections
.iter()
.any(|c| c.workspace_root.is_some()),
"the workspace tab was opened"
);
let _ = std::fs::remove_dir_all(&base);
}
fn pump_save_until_past(save: &mut SaveFlow, app: &mut GuiApp, step: SaveStep) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
if save.poll(app) {
return true;
}
if save.step() != step || save.error().is_some() {
break;
}
assert!(
std::time::Instant::now() < deadline,
"stuck on {step:?} (error: {:?})",
save.error()
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_eq!(save.error(), None, "flow failed on {step:?}");
false
}
fn pump_save_until_refs(save: &mut SaveFlow, app: &mut GuiApp) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while save.is_busy() {
assert!(!save.poll(app), "the flow finished before the refs arrived");
assert!(
std::time::Instant::now() < deadline,
"the ref listing never arrived (error: {:?})",
save.error()
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_eq!(save.error(), None, "listing the refs failed");
}
fn session_with_collection() -> Session {
let mut session = Session::default();
session.collections.clear();
let col = Collection::new(
"Demo".to_string(),
vec![HurlEntry {
method: "GET".to_string(),
url: "https://example.com/a".to_string(),
user_added: true,
..Default::default()
}],
);
session.collections.push(col);
session
}
#[test]
fn saving_a_collection_that_is_gone_is_blocked_up_front() {
let app = GuiApp::for_test(Session::default());
let gone = app.session.collections.len();
let mut save = SaveFlow::new(SaveTarget::Collection(gone));
save.ensure_initialized(&app);
assert!(save.blocked);
assert_eq!(
save.error(),
Some(app.strings.gui_git_err_collection_missing)
);
assert!(save.flow.is_none(), "no push machinery is built at all");
}
#[test]
fn each_save_target_titles_its_own_dialog() {
let s = crate::i18n::Strings::for_language(&crate::i18n::Language::English);
let title = |t| Flow::Save(SaveFlow::new(t)).title(&s);
assert_eq!(
title(SaveTarget::Collection(0)),
s.gui_git_save_collection_title
);
assert_eq!(
title(SaveTarget::Workspace(0)),
s.gui_git_save_workspace_title
);
assert_eq!(title(SaveTarget::Report), s.gui_git_save_report_title);
}
#[test]
fn saving_a_workspace_that_did_not_come_from_git_is_blocked() {
let app = GuiApp::for_test(session_with_collection());
let mut save = SaveFlow::new(SaveTarget::Workspace(0));
save.ensure_initialized(&app);
assert!(save.blocked);
assert_eq!(save.error(), Some(app.strings.gui_git_err_ws_not_from_git));
}
#[test]
fn the_save_step_shown_follows_the_shared_flow() {
let app = GuiApp::for_test(session_with_collection());
let mut save = SaveFlow::new(SaveTarget::Collection(0));
save.ensure_initialized(&app);
assert_eq!(save.step(), SaveStep::Connect);
let flow = save.flow.as_mut().unwrap();
flow.seed_step(CoreStep::ChoosePaths);
assert_eq!(save.step(), SaveStep::ChoosePaths);
save.flow
.as_mut()
.unwrap()
.seed_step(CoreStep::ChooseTarget);
assert_eq!(save.step(), SaveStep::ChooseTarget);
save.flow
.as_mut()
.unwrap()
.seed_step(CoreStep::CommitMessage);
assert_eq!(save.step(), SaveStep::CommitMessage);
save.flow.as_mut().unwrap().seed_step(CoreStep::Done);
assert_eq!(save.step(), SaveStep::Done);
}
#[test]
fn a_failed_save_returns_to_the_first_step_with_the_reason() {
let app = GuiApp::for_test(session_with_collection());
let mut save = SaveFlow::new(SaveTarget::Collection(0));
save.ensure_initialized(&app);
save.flow
.as_mut()
.unwrap()
.seed_step(CoreStep::Failed("remote hung up".into()));
assert_eq!(save.step(), SaveStep::Connect);
assert_eq!(save.error(), Some("remote hung up"));
save.clear_errors();
assert_eq!(save.error(), None);
}
#[test]
fn a_save_path_that_escapes_the_repository_is_refused() {
let app = GuiApp::for_test(session_with_collection());
let s = Strings::for_language(&Language::English);
let mut save = SaveFlow::new(SaveTarget::Collection(0));
save.ensure_initialized(&app);
let flow = save.flow.as_mut().unwrap();
flow.seed_step(CoreStep::ChoosePaths);
save.remember_step();
let flow = save.flow.as_mut().unwrap();
flow.path = "../outside.hurl".to_string();
assert!(!flow.submit_paths(&s));
assert_eq!(save.step(), SaveStep::ChoosePaths, "the user stays put");
assert!(save.error().is_some());
}
#[test]
fn a_collection_can_be_saved_to_a_real_repository_end_to_end() {
let (repo_url, base) = seed_bare_repo();
let mut app = GuiApp::for_test(session_with_collection());
let s = Strings::for_language(&Language::English);
let mut save = SaveFlow::new(SaveTarget::Collection(0));
save.ensure_initialized(&app);
let flow = save.flow.as_mut().unwrap();
flow.url = repo_url.clone();
flow.submit_connect(&s);
assert_eq!(save.step(), SaveStep::ChoosePaths);
let flow = save.flow.as_mut().unwrap();
flow.path = "demo.hurl".to_string();
assert!(flow.submit_paths(&s));
pump_save_until_refs(&mut save, &mut app);
let flow = save.flow.as_mut().unwrap();
assert!(
flow.refs().branches.iter().any(|b| b == "main"),
"the branch listing arrived so an existing branch can be appended to"
);
flow.target_name = "main".to_string();
assert!(flow.submit_target());
assert_eq!(save.step(), SaveStep::CommitMessage);
save.flow.as_mut().unwrap().message = "from the GUI".to_string();
start_save(&mut save, &mut app);
assert!(
pump_save_until_past(&mut save, &mut app, SaveStep::CommitMessage),
"the push completed"
);
let bare = base.join("bare.git");
let out = std::process::Command::new("git")
.args([
"--git-dir",
bare.to_str().unwrap(),
"show",
"main:demo.hurl",
])
.output()
.unwrap();
assert!(out.status.success(), "demo.hurl is on main: {out:?}");
assert!(
String::from_utf8_lossy(&out.stdout).contains("https://example.com/a"),
"the request itself was committed, not an empty file"
);
let col = &app.session.collections[0];
assert_eq!(
col.git_origin.as_ref().map(|o| o.path.as_str()),
Some("demo.hurl"),
"the collection remembers where it now lives"
);
assert!(
col.entries.iter().all(|e| !e.user_added && !e.modified),
"the push counts as this tab's save, so the edit markers are cleared"
);
let _ = std::fs::remove_dir_all(&base);
}
}