use std::path::Path;
use eframe::egui;
use crate::i18n::{Language, Strings};
use crate::request::RequestView;
use crate::session::PickerKind;
use crate::theme::{THEME_COLOR_COUNT, ThemeSpec, is_builtin};
use super::app::{Dialog, GuiApp, OpenKind, PromptKind, RenameTarget, SaveKind};
pub struct ThemeEditState {
pub spec: ThemeSpec,
pub original_name: String,
}
#[derive(Default)]
pub struct AltMenus {
armed: bool,
alt_alone: bool,
alt_was_down: bool,
ids: Vec<(char, egui::Id)>,
}
impl AltMenus {
pub fn is_armed(&self) -> bool {
self.armed
}
fn register(&mut self, mnemonic: char, id: egui::Id) {
self.ids.retain(|(c, _)| *c != mnemonic);
self.ids.push((mnemonic, id));
}
fn id_for(&self, c: char) -> Option<egui::Id> {
self.ids
.iter()
.find(|(m, _)| m.eq_ignore_ascii_case(&c))
.map(|(_, id)| *id)
}
}
fn mnemonic_char(s: &str) -> Option<char> {
s.chars().next().map(|c| c.to_ascii_uppercase())
}
fn handle_menu_mnemonics(app: &mut GuiApp, ctx: &egui::Context) {
if ctx.memory(|m| m.focused().is_some()) {
app.alt_menus.armed = false;
app.alt_menus.alt_alone = false;
return;
}
struct Frame {
alt_down: bool,
escape: bool,
pressed: Vec<(char, bool)>,
}
let f = ctx.input(|i| Frame {
alt_down: i.modifiers.alt,
escape: i.key_pressed(egui::Key::Escape),
pressed: i
.events
.iter()
.filter_map(|e| match e {
egui::Event::Key {
key,
pressed: true,
modifiers,
..
} => mnemonic_char(key.name()).map(|c| (c, modifiers.alt)),
_ => None,
})
.collect(),
});
if f.alt_down && !app.alt_menus.alt_was_down {
app.alt_menus.alt_alone = true;
}
if !f.pressed.is_empty() {
app.alt_menus.alt_alone = false;
}
let mut opened = false;
for (c, with_alt) in &f.pressed {
if (*with_alt || app.alt_menus.armed)
&& let Some(id) = app.alt_menus.id_for(*c)
{
egui::Popup::open_id(ctx, id.with("popup"));
opened = true;
break;
}
}
if opened || f.escape {
app.alt_menus.armed = false;
app.alt_menus.alt_alone = false;
} else if !f.alt_down && app.alt_menus.alt_was_down && app.alt_menus.alt_alone {
app.alt_menus.armed = !app.alt_menus.armed;
app.alt_menus.alt_alone = false;
}
app.alt_menus.alt_was_down = f.alt_down;
}
fn menu_title(ui: &egui::Ui, title: &str, mnemonic: char, armed: bool) -> egui::WidgetText {
if !armed {
return title.into();
}
let Some(pos) = title.to_uppercase().find(mnemonic) else {
return title.into();
};
let font = egui::TextStyle::Button.resolve(ui.style());
let color = ui.visuals().widgets.inactive.fg_stroke.color;
let mut job = egui::text::LayoutJob::default();
let mut push = |s: &str, underline: bool| {
if s.is_empty() {
return;
}
job.append(
s,
0.0,
egui::TextFormat {
font_id: font.clone(),
color,
underline: if underline {
egui::Stroke::new(1.0, color)
} else {
egui::Stroke::NONE
},
..Default::default()
},
);
};
let end = pos + title[pos..].chars().next().map_or(0, |c| c.len_utf8());
push(&title[..pos], false);
push(&title[pos..end], true);
push(&title[end..], false);
job.into()
}
pub fn menu_bar(app: &mut GuiApp, ui: &mut egui::Ui) {
handle_menu_mnemonics(app, ui.ctx());
egui::MenuBar::new().ui(ui, |ui| {
file_menu(app, ui);
settings_menu(app, ui);
view_menu(app, ui);
});
}
fn top_menu<R>(
app: &mut GuiApp,
ui: &mut egui::Ui,
title: &str,
mnemonic: &str,
content: impl FnOnce(&mut GuiApp, &mut egui::Ui) -> R,
) {
let armed = app.alt_menus.is_armed();
let Some(m) = mnemonic_char(mnemonic) else {
return;
};
let text = menu_title(ui, title, m, armed);
let resp = ui.menu_button(text, |ui| content(app, ui));
app.alt_menus.register(m, resp.response.id);
}
fn file_menu(app: &mut GuiApp, ui: &mut egui::Ui) {
let (title, mnemonic) = (app.strings.gui_menu_file, app.strings.gui_menu_file_key);
top_menu(app, ui, title, mnemonic, |app, ui| {
if ui.button(app.strings.gui_new_collection_ellipsis).clicked() {
app.dialog = Some(Dialog::Prompt {
kind: PromptKind::NewCollectionName,
text: String::new(),
});
ui.close();
}
ui.separator();
let mut close_menu = false;
ui.menu_button(app.strings.gui_menu_import, |ui| {
if ui
.button(app.strings.gui_menu_import_file)
.on_hover_text(app.strings.help_menu_import_file)
.clicked()
{
open_via_picker(app, OpenKind::PostmanExport);
close_menu = true;
ui.close();
}
if ui
.button(app.strings.gui_menu_import_account)
.on_hover_text(app.strings.help_menu_import_account)
.clicked()
{
app.postman.open();
close_menu = true;
ui.close();
}
});
ui.separator();
ui.menu_button(app.strings.gui_menu_open, |ui| {
ui.menu_button(app.strings.file_kind_collection, |ui| {
if ui.button(app.strings.gui_menu_from_file).clicked() {
open_via_picker(app, OpenKind::Collection);
close_menu = true;
ui.close();
}
if ui.button(app.strings.gui_menu_from_git).clicked() {
app.remote.open_load();
close_menu = true;
ui.close();
}
});
ui.menu_button(app.strings.file_kind_environment, |ui| {
if ui.button(app.strings.gui_menu_from_file).clicked() {
open_via_picker(app, OpenKind::Environment);
close_menu = true;
ui.close();
}
if ui.button(app.strings.gui_menu_from_git).clicked() {
app.remote.open_load();
close_menu = true;
ui.close();
}
});
ui.menu_button(app.strings.file_kind_report, |ui| {
if ui.button(app.strings.gui_menu_from_file).clicked() {
open_via_picker(app, OpenKind::Report);
close_menu = true;
ui.close();
}
if ui.button(app.strings.gui_menu_from_git).clicked() {
app.remote.open_load_report();
close_menu = true;
ui.close();
}
});
ui.menu_button(app.strings.file_kind_workspace, |ui| {
if ui.button(app.strings.gui_menu_from_folder).clicked() {
open_via_picker(app, OpenKind::Workspace);
close_menu = true;
ui.close();
}
if ui.button(app.strings.gui_menu_from_git).clicked() {
app.remote.open_load_workspace();
close_menu = true;
ui.close();
}
if ui.button(app.strings.gui_menu_from_postman).clicked() {
app.postman.open();
close_menu = true;
ui.close();
}
});
});
ui.separator();
let save_hint = if save_active_has_path(app) {
app.strings.help_menu_save
} else {
app.strings.help_menu_save_unsaved
};
if ui
.button(format!(
"{}\t{}",
app.strings.gui_menu_save, app.strings.gui_shortcut_save
))
.on_hover_text(save_hint)
.clicked()
{
save_active(app);
close_menu = true;
ui.close();
}
ui.menu_button(app.strings.gui_menu_save_as, |ui| {
ui.menu_button(app.strings.file_kind_collection, |ui| {
if ui.button(app.strings.gui_menu_to_file).clicked() {
save_via_picker(app, SaveKind::Collection);
close_menu = true;
ui.close();
}
if ui.button(app.strings.gui_menu_to_git).clicked() {
app.remote.open_save_collection(app.active_ci());
close_menu = true;
ui.close();
}
});
let has_report = app.report_editor.is_some();
ui.menu_button(app.strings.file_kind_report, |ui| {
if ui
.add_enabled(has_report, egui::Button::new(app.strings.gui_menu_to_file))
.clicked()
{
save_via_picker(app, SaveKind::Report);
close_menu = true;
ui.close();
}
if ui
.add_enabled(has_report, egui::Button::new(app.strings.gui_menu_to_git))
.clicked()
{
app.remote.open_save_report();
close_menu = true;
ui.close();
}
});
let ci = app.active_ci();
let is_ws = app
.session
.collections
.get(ci)
.is_some_and(|c| c.workspace_git_origin.is_some());
ui.menu_button(app.strings.file_kind_workspace, |ui| {
if ui
.add_enabled(is_ws, egui::Button::new(app.strings.gui_menu_to_git))
.clicked()
{
app.remote.open_save_workspace(ci);
close_menu = true;
ui.close();
}
});
ui.menu_button(app.strings.gui_menu_kind_response, |ui| {
if ui.button(app.strings.gui_menu_to_file).clicked() {
save_via_picker(app, SaveKind::Response);
close_menu = true;
ui.close();
}
});
});
ui.separator();
if ui.button(app.strings.gui_set_base_url).clicked() {
app.dialog = Some(Dialog::Prompt {
kind: PromptKind::BaseUrl,
text: app.session.vars.base_url.clone(),
});
ui.close();
}
ui.separator();
if ui.button(app.strings.gui_close_tab).clicked() {
app.request_close_tab(app.active_ci());
ui.close();
}
if ui.button(app.strings.gui_quit).clicked() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
if close_menu {
ui.close();
}
});
}
fn settings_menu(app: &mut GuiApp, ui: &mut egui::Ui) {
let (title, mnemonic) = (
app.strings.gui_menu_settings,
app.strings.gui_menu_settings_key,
);
top_menu(app, ui, title, mnemonic, |app, ui| {
ui.menu_button(app.strings.gui_language, |ui| {
for (lang, label) in [
(Language::English, app.strings.lang_english),
(Language::French, app.strings.lang_french),
(Language::Danish, app.strings.lang_danish),
] {
if ui.radio(app.session.language == lang, label).clicked() {
app.session.language = lang;
app.session.save();
ui.close();
}
}
});
ui.menu_button(app.strings.gui_theme_menu, |ui| {
let active = app.session.active_theme.clone();
for spec in app.session.all_themes() {
let selected = active.as_deref() == Some(spec.name.as_str());
if ui.radio(selected, &spec.name).clicked() {
app.session.active_theme = Some(spec.name.clone());
app.session.save();
ui.close();
}
}
ui.separator();
if ui.button(app.strings.gui_follow_language).clicked() {
app.session.active_theme = None;
app.session.save();
ui.close();
}
ui.separator();
if ui.button(app.strings.gui_new_custom_theme).clicked() {
let mut spec = app.session.active_theme_spec();
spec.name = unique_theme_name(app, app.strings.gui_custom);
app.dialog = Some(Dialog::Theme(Box::new(ThemeEditState {
original_name: spec.name.clone(),
spec,
})));
ui.close();
}
let editable = active.as_ref().map(|n| !is_builtin(n)).unwrap_or(false);
if ui
.add_enabled(
editable,
egui::Button::new(app.strings.gui_edit_current_theme),
)
.clicked()
{
let spec = app.session.active_theme_spec();
app.dialog = Some(Dialog::Theme(Box::new(ThemeEditState {
original_name: spec.name.clone(),
spec,
})));
ui.close();
}
});
ui.separator();
ui.colored_label(app.theme.dim, app.strings.gui_preferences);
if ui
.checkbox(
&mut app.session.confirm_on_exit,
app.strings.gui_confirm_on_exit,
)
.changed()
{
app.session.save();
}
if ui
.checkbox(
&mut app.session.confirm_on_clear,
app.strings.gui_confirm_on_clear,
)
.changed()
{
app.session.save();
}
if ui
.checkbox(
&mut app.session.confirm_on_delete_env,
app.strings.gui_confirm_delete_env,
)
.changed()
{
app.session.save();
}
if ui
.checkbox(
&mut app.session.run_all_batch_mode,
app.strings.gui_run_all_batch,
)
.changed()
{
app.session.save();
}
let mut hurl_default = app.session.default_request_view == RequestView::Hurl;
if ui
.checkbox(&mut hurl_default, app.strings.gui_default_code_hurl)
.changed()
{
app.session.default_request_view = if hurl_default {
RequestView::Hurl
} else {
RequestView::Json
};
app.show_hurl = hurl_default;
app.session.save();
}
});
}
fn view_menu(app: &mut GuiApp, ui: &mut egui::Ui) {
let (title, mnemonic) = (app.strings.gui_menu_view, app.strings.gui_menu_view_key);
top_menu(app, ui, title, mnemonic, |app, ui| {
if ui
.radio(!app.show_reports, app.strings.gui_request_response)
.clicked()
{
app.show_reports = false;
ui.close();
}
if ui
.radio(app.show_reports, app.strings.gui_reports)
.clicked()
{
app.show_reports = true;
ui.close();
}
});
}
fn unique_theme_name(app: &GuiApp, base: &str) -> String {
let exists = |name: &str| app.session.all_themes().iter().any(|t| t.name == name);
if !exists(base) {
return base.to_string();
}
let mut n = 2;
while exists(&format!("{base} {n}")) {
n += 1;
}
format!("{base} {n}")
}
pub fn show_dialog(app: &mut GuiApp, ctx: &egui::Context) {
let Some(dialog) = app.dialog.take() else {
return;
};
match dialog {
Dialog::Rename { target, text } => rename_dialog(app, ctx, target, text),
Dialog::Prompt { kind, text } => prompt_dialog(app, ctx, kind, text),
Dialog::Theme(state) => theme_dialog(app, ctx, *state),
Dialog::CloseGitWorkspace { ci, root } => close_git_workspace_dialog(app, ctx, ci, root),
Dialog::UnsavedQuit { count, tabs } => unsaved_quit_dialog(app, ctx, count, tabs),
Dialog::UnsavedCloseTab { ci, name, count } => {
unsaved_close_tab_dialog(app, ctx, ci, name, count)
}
Dialog::WorkspaceReload { ci, reload } => workspace_reload_dialog(app, ctx, ci, *reload),
Dialog::ExportResults { path } => export_results_dialog(app, ctx, path),
Dialog::RevertToSaved {
ci,
path,
entry,
name,
} => revert_to_saved_dialog(app, ctx, ci, path, entry, name),
}
}
fn revert_to_saved_dialog(
app: &mut GuiApp,
ctx: &egui::Context,
ci: usize,
path: std::path::PathBuf,
entry: Option<usize>,
name: String,
) {
let title = app.strings.gui_revert_title;
let (go, cancel, question) = (
app.strings.gui_revert_go,
app.strings.gui_cancel,
match entry {
Some(_) => app.strings.confirm_revert_request_q.replace("{r}", &name),
None => app.strings.confirm_revert_file_q.replace("{f}", &name),
},
);
let mut decided = false;
let dismissed = modal(ctx, title, |ui| {
ui.colored_label(app.theme.text, question);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button(go).clicked() {
match entry {
Some(ei) => {
app.session.collections[ci].revert_request(ei);
}
None => {
let _ = app.session.collections[ci].revert_workspace_file(&path);
}
}
decided = true;
}
if ui.button(cancel).clicked() {
decided = true;
}
});
})
.dismissed;
decided |= dismissed;
if !decided {
app.dialog = Some(Dialog::RevertToSaved {
ci,
path,
entry,
name,
});
} else {
app.session.save();
}
}
fn export_results_dialog(app: &mut GuiApp, ctx: &egui::Context, mut path: String) {
let title = app.strings.gui_save_results_title;
let (lbl_export, lbl_cancel, lbl_browse, lbl_format) = (
app.strings.gui_report_export_go,
app.strings.gui_cancel,
app.strings.gui_browse,
app.strings.gui_report_export_format,
);
let mut act = ExportChoice::Keep;
let theme_dim = app.theme.dim;
let answered = modal(ctx, title, |ui| {
ui.horizontal(|ui| {
ui.colored_label(theme_dim, lbl_format);
let current = std::path::Path::new(&path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
egui::ComboBox::from_id_salt("pb_export_format")
.selected_text(format_label(¤t))
.show_ui(ui, |ui| {
for ext in crate::report::writer::OUTPUT_EXTENSIONS {
if ui
.selectable_label(ext == current, format_label(ext))
.clicked()
&& ext != current
{
path = retarget_extension(&path, ext);
}
}
});
});
let resp = ui.add(
egui::TextEdit::singleline(&mut path)
.desired_width(420.0)
.hint_text(".csv / .json / .html / .xlsx"),
);
let entered = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.horizontal(|ui| {
if ui.button(lbl_browse).clicked() {
act = ExportChoice::Browse;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(lbl_cancel).clicked() {
act = ExportChoice::Cancel;
}
if ui.button(lbl_export).clicked() || entered {
act = ExportChoice::Write;
}
});
});
});
if answered.inner.is_none() {
act = ExportChoice::Keep;
}
if answered.dismissed {
act = ExportChoice::Cancel;
}
match act {
ExportChoice::Keep => app.dialog = Some(Dialog::ExportResults { path }),
ExportChoice::Cancel => {}
ExportChoice::Browse => save_via_picker(app, SaveKind::ReportResults),
ExportChoice::Write => {
match export_report_results(app, &path) {
Ok(()) => {
app.session.status = Some(crate::i18n::Status::Saved);
}
Err(e) => {
app.session.status = Some(crate::i18n::Status::Error(e));
app.dialog = Some(Dialog::ExportResults { path });
}
}
}
}
}
enum ExportChoice {
Keep,
Cancel,
Browse,
Write,
}
fn format_label(ext: &str) -> &'static str {
match ext {
"json" => "JSON",
"html" | "htm" => "HTML",
"xlsx" => "Excel",
"pdf" => "PDF",
"csv" => "CSV",
_ => "—",
}
}
fn retarget_extension(path: &str, ext: &str) -> String {
let p = std::path::Path::new(path);
if p.as_os_str().is_empty() {
return format!("results.{ext}");
}
p.with_extension(ext).to_string_lossy().into_owned()
}
fn unsaved_quit_dialog(app: &mut GuiApp, ctx: &egui::Context, count: usize, tabs: String) {
let title = app.strings.gui_unsaved_quit_title;
let (quit, cancel, question) = (
app.strings.gui_quit_anyway,
app.strings.gui_cancel,
app.strings
.gui_unsaved_quit_q
.replace("{n}", &count.to_string())
.replace("{t}", &tabs),
);
let save_all = app.strings.gui_save_all_and_quit;
let mut decided = false;
let mut save_then_quit = false;
let decided = modal(ctx, title, |ui| {
ui.colored_label(app.theme.text, question);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button(save_all).clicked() {
save_then_quit = true;
decided = true;
}
if ui.button(quit).clicked() {
app.allow_close = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
decided = true;
}
if ui.button(cancel).clicked() {
decided = true;
}
});
})
.dismissed
|| decided;
if save_then_quit {
match app.save_all_unsaved_edits() {
Ok(_) => {
app.allow_close = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
Err(e) => {
app.session.status = Some(crate::i18n::Status::Error(e));
app.dialog = Some(Dialog::UnsavedQuit { count, tabs });
}
}
return;
}
if !decided {
app.dialog = Some(Dialog::UnsavedQuit { count, tabs });
}
}
fn unsaved_close_tab_dialog(
app: &mut GuiApp,
ctx: &egui::Context,
ci: usize,
name: String,
count: usize,
) {
let title = app.strings.gui_unsaved_quit_title;
let (close, cancel, question) = (
app.strings.gui_close_anyway,
app.strings.gui_cancel,
app.strings
.gui_unsaved_close_tab_q
.replace("{n}", &count.to_string())
.replace("{t}", &name),
);
let mut decided = false;
let dismissed = modal(ctx, title, |ui| {
ui.colored_label(app.theme.text, question);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button(close).clicked() {
app.close_tab_now(ci);
decided = true;
}
if ui.button(cancel).clicked() {
decided = true;
}
});
})
.dismissed;
decided |= dismissed;
if !decided {
app.dialog = Some(Dialog::UnsavedCloseTab { ci, name, count });
} else {
app.session.save();
}
}
fn close_git_workspace_dialog(
app: &mut GuiApp,
ctx: &egui::Context,
ci: usize,
root: std::path::PathBuf,
) {
let title = app.strings.gui_close_git_workspace_title;
let (keep, delete, cancel, question) = (
app.strings.close_git_workspace_keep,
app.strings.close_git_workspace_delete,
app.strings.close_git_workspace_cancel,
app.strings
.close_git_workspace_q
.replace("{p}", &root.display().to_string()),
);
let mut decided = false;
let dismissed = modal(ctx, title, |ui| {
ui.colored_label(app.theme.text, question);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button(keep).clicked() {
app.session.close_tab(ci);
decided = true;
}
if ui.button(delete).clicked() {
app.session.close_tab_deleting_workspace(ci);
decided = true;
}
if ui.button(cancel).clicked() {
decided = true;
}
});
})
.dismissed;
decided |= dismissed;
if !decided {
app.dialog = Some(Dialog::CloseGitWorkspace { ci, root });
} else {
app.session.save();
}
}
fn workspace_reload_dialog(
app: &mut GuiApp,
ctx: &egui::Context,
ci: usize,
reload: crate::persistence::PendingWorkspaceReload,
) {
let title = app.strings.gui_workspace_reload_title;
let ref_label = if reload.origin.ref_kind == crate::git_remote::RefKind::Branch {
app.strings.git_branches
} else {
app.strings.git_tags
};
let question = app
.strings
.workspace_reload_confirm_q
.replace("{name}", &reload.tab_name)
.replace(
"{ref}",
&format!("[{ref_label}] {}", reload.origin.ref_name),
)
.replace("{url}", &reload.origin.repo_url);
let (yes, no, hint) = (
app.strings.gui_workspace_reload_yes,
app.strings.gui_workspace_reload_no,
app.strings.workspace_reload_save_hint,
);
let mut answer: Option<bool> = None;
let dismissed = modal(ctx, title, |ui| {
ui.colored_label(app.theme.text, question);
ui.add_space(4.0);
ui.colored_label(app.theme.dim, hint);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button(yes).clicked() {
answer = Some(true);
}
if ui.button(no).clicked() {
answer = Some(false);
}
});
})
.dismissed;
if dismissed {
answer = Some(false);
}
match answer {
Some(true) => app.start_workspace_redownload(ci, reload),
Some(false) => {}
None => {
app.dialog = Some(Dialog::WorkspaceReload {
ci,
reload: Box::new(reload),
});
}
}
}
fn modal<R>(
ctx: &egui::Context,
title: &str,
add: impl FnOnce(&mut egui::Ui) -> R,
) -> super::widgets::DialogFrame<R> {
super::widgets::dialog(ctx, title, None, add)
}
fn open_title(app: &GuiApp, kind: OpenKind) -> &'static str {
match kind {
OpenKind::Collection => app.strings.gui_open_collection_title,
OpenKind::Environment => app.strings.gui_open_environment_title,
OpenKind::Workspace => app.strings.gui_open_workspace_title,
OpenKind::Report => app.strings.gui_open_report_title,
OpenKind::PostmanExport => app.strings.postman_export_open_title,
}
}
fn open_picker_kind(kind: OpenKind) -> PickerKind {
match kind {
OpenKind::Environment => PickerKind::Environment,
_ => PickerKind::Other,
}
}
pub fn open_via_picker(app: &mut GuiApp, kind: OpenKind) {
let title = open_title(app, kind);
let dir = app
.session
.picker_dir(open_picker_kind(kind))
.map(|p| p.to_path_buf());
let filters = |f: &[super::filepick::Filter]| super::filepick::owned_filters(f);
let pick_kind = match kind {
OpenKind::Workspace => super::filepick::PickKind::Folder,
OpenKind::Collection => super::filepick::PickKind::File {
filters: filters(&[
(app.strings.gui_filter_collections, &["hurl", "json"]),
(app.strings.gui_filter_all, &["*"]),
]),
},
OpenKind::Environment => super::filepick::PickKind::File {
filters: filters(&[
(
app.strings.gui_filter_environments,
&["vars", "env", "json"],
),
(app.strings.gui_filter_all, &["*"]),
]),
},
OpenKind::Report => super::filepick::PickKind::File {
filters: filters(&[
(app.strings.gui_filter_reports, &["trail"]),
(app.strings.gui_filter_all, &["*"]),
]),
},
OpenKind::PostmanExport => super::filepick::PickKind::File {
filters: filters(&[
(app.strings.gui_filter_postman_export, &["json"]),
(app.strings.gui_filter_all, &["*"]),
]),
},
};
app.request_pick(pick_kind, title, dir.as_deref(), PickAction::Open(kind));
}
pub enum PickAction {
Open(OpenKind),
Save(SaveKind),
ReportHeaderFile {
key: &'static str,
occurrence: usize,
},
ReportParamPath {
name: String,
},
ReportLoopDir {
path: Vec<usize>,
file: bool,
},
PostmanDest,
ReportWizardDir,
NewWorkspaceItem {
ci: usize,
kind: crate::workspace::NewItemKind,
},
FormFieldFile {
ci: usize,
entry: usize,
field: usize,
},
GitWorkspaceDir,
}
pub fn poll_pending_pick(app: &mut GuiApp) {
let Some(pending) = app.pending_pick.as_mut() else {
return;
};
let Some((action, picked)) = pending.take() else {
return; };
app.pending_pick = None;
match action {
PickAction::ReportParamPath { name } => {
if let Some(path) = picked.as_ref()
&& let Some(ed) = app.report_editor.as_mut()
{
ed.param_values
.insert(name, path.to_string_lossy().into_owned());
}
return;
}
PickAction::ReportHeaderFile { key, occurrence } => {
super::report_editor::apply_picked_header_file(app, key, occurrence, picked);
return;
}
PickAction::ReportLoopDir { path, file } => {
super::report_editor::apply_picked_loop_dir(app, &path, file, picked);
return;
}
PickAction::PostmanDest => {
super::postman::apply_picked_dest(app, picked);
return;
}
PickAction::ReportWizardDir => {
super::report_wizard::apply_picked_dir(app, picked);
return;
}
PickAction::NewWorkspaceItem { ci, kind } => {
super::requests::apply_new_workspace_item(app, ci, kind, picked);
return;
}
PickAction::FormFieldFile { ci, entry, field } => {
super::editor::apply_picked_form_file(app, ci, entry, field, picked);
return;
}
PickAction::GitWorkspaceDir => {
super::remote::apply_picked_workspace_dir(app, picked);
return;
}
_ => {}
}
let (title, picker_kind) = match action {
PickAction::Open(kind) => (open_title(app, kind), open_picker_kind(kind)),
PickAction::Save(kind) => (save_title(app, kind), save_picker_kind(kind)),
_ => unreachable!("handled above"),
};
let Some(path) = picked else {
return; };
app.session.remember_picker_dir(picker_kind, &path);
let outcome = match action {
PickAction::Open(kind) => apply_open(app, kind, &path),
PickAction::Save(kind) => apply_save(app, kind, &path),
_ => unreachable!("handled above"),
};
if let Err(msg) = outcome {
super::filepick::error_alert(title, &msg);
}
app.session.save();
}
fn apply_open(app: &mut GuiApp, kind: OpenKind, path: &Path) -> Result<(), String> {
if kind == OpenKind::Workspace {
if path.is_dir() {
app.session.open_workspace(path.to_path_buf());
app.focus = super::Focus::List;
app.close_report_editor();
return Ok(());
}
return Err(app.strings.gui_not_a_folder.to_string());
}
let path_str = path.to_string_lossy().into_owned();
let content = std::fs::read_to_string(path)
.map_err(|e| format!("{} {e}", app.strings.gui_could_not_read))?;
let name = file_stem(&path_str);
if kind == OpenKind::Report {
let report = crate::report::Report::load_local(path)?;
let existing = app
.session
.reports
.iter()
.position(|r| r.path.as_deref() == Some(&path_str));
let idx = match existing {
Some(i) => i,
None => {
app.session
.reports
.push(crate::persistence::PersistedReport {
name: report.name.clone(),
text: report.text.clone(),
path: Some(path_str.clone()),
git_origin: None,
workspace_root: None,
embedded_active: true,
});
app.session.reports.len() - 1
}
};
app.open_report_editor(
crate::gui::report_editor::ReportOrigin::Session(idx),
report,
);
app.focus = super::Focus::Main;
return Ok(());
}
let kind = match kind {
OpenKind::PostmanExport => match crate::postman::export_kind(&content) {
Some(crate::postman::ExportKind::Collection) => OpenKind::Collection,
Some(crate::postman::ExportKind::Environment) => OpenKind::Environment,
None => return Err(app.strings.gui_not_postman_export.to_string()),
},
other => other,
};
let ok = match kind {
OpenKind::Collection => {
app.session
.load_collection_text(name, &content, Some(path.to_path_buf()))
}
OpenKind::Environment => app
.session
.load_environment_text(name, &content, Some(path.to_path_buf()), None)
.is_some(),
OpenKind::Workspace | OpenKind::Report | OpenKind::PostmanExport => unreachable!(),
};
if ok {
Ok(())
} else {
Err(app.strings.gui_could_not_parse.to_string())
}
}
fn report_result_filters(ext: &str) -> Vec<super::filepick::Filter<'static>> {
const ALL: [super::filepick::Filter<'static>; 5] = [
("CSV", &["csv"]),
("JSON", &["json"]),
("HTML", &["html"]),
("Excel", &["xlsx"]),
("PDF", &["pdf"]),
];
let want = ext.to_ascii_lowercase();
let mut filters = ALL.to_vec();
if let Some(i) = filters
.iter()
.position(|(_, exts)| exts.contains(&want.as_str()))
{
let leading = filters.remove(i);
filters.insert(0, leading);
}
filters
}
fn save_title(app: &GuiApp, kind: SaveKind) -> &'static str {
match kind {
SaveKind::Collection => app.strings.gui_save_collection_title,
SaveKind::Environment(_) => app.strings.gui_save_environment_title,
SaveKind::Response => app.strings.gui_save_response_title,
SaveKind::ReportResults => app.strings.gui_save_results_title,
SaveKind::ReportBaseline => app.strings.gui_save_baseline_title,
SaveKind::Report => app.strings.gui_save_report_title,
}
}
fn save_picker_kind(kind: SaveKind) -> PickerKind {
match kind {
SaveKind::Environment(_) => PickerKind::Environment,
_ => PickerKind::Other,
}
}
pub fn active_save_kind(app: &GuiApp) -> SaveKind {
if app.report_editor.is_some() {
SaveKind::Report
} else {
SaveKind::Collection
}
}
pub fn save_active(app: &mut GuiApp) {
match active_save_kind(app) {
SaveKind::Report => match app.report_editor.as_mut() {
Some(ed) if ed.report.path.is_some() => {
super::report_editor::request_save(ed);
}
_ => save_via_picker(app, SaveKind::Report),
},
_ => {
let ci = app.active_ci();
let Some(path) = app.session.collections.get(ci).and_then(|c| c.path.clone()) else {
save_via_picker(app, SaveKind::Collection);
return;
};
let text = app.session.collections[ci].to_hurl();
match std::fs::write(&path, text) {
Ok(()) => {
app.session.collections[ci].mark_saved();
app.session.save();
app.session.status = Some(crate::i18n::Status::Saved);
}
Err(e) => {
app.session.status = Some(crate::i18n::Status::Error(format!(
"{} {e}",
app.strings.gui_could_not_write
)));
}
}
}
}
}
pub fn save_active_has_path(app: &GuiApp) -> bool {
match active_save_kind(app) {
SaveKind::Report => app
.report_editor
.as_ref()
.is_some_and(|e| e.report.path.is_some()),
_ => app
.session
.collections
.get(app.active_ci())
.is_some_and(|c| c.path.is_some()),
}
}
pub fn save_via_picker(app: &mut GuiApp, kind: SaveKind) {
let title = save_title(app, kind);
let current = match kind {
SaveKind::Collection => app.session.collections[app.active_ci()]
.path
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
SaveKind::Environment(id) => app
.session
.global_envs
.iter()
.find(|e| e.id == id)
.and_then(|e| e.path.as_ref())
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
SaveKind::ReportResults => app
.report_editor
.as_ref()
.map(|e| {
crate::report::writer::export_path(
&e.report,
&crate::report::writer::report_output_extension(&e.report),
)
})
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
SaveKind::ReportBaseline => app
.report_editor
.as_ref()
.map(|e| crate::report::writer::export_path(&e.report, "baseline"))
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
SaveKind::Report => app
.report_editor
.as_ref()
.and_then(|e| e.report.path.clone())
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| {
app.report_editor
.as_ref()
.map(|e| format!("{}.trail", e.report.name))
.unwrap_or_default()
}),
SaveKind::Response => String::new(),
};
let picker_kind = save_picker_kind(kind);
let dir = super::filepick::seed_dir(¤t)
.or_else(|| app.session.picker_dir(picker_kind).map(|p| p.to_path_buf()));
let default_name = std::path::Path::new(¤t)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| match kind {
SaveKind::Collection => "collection.hurl".into(),
SaveKind::Environment(_) => "environment.vars".into(),
SaveKind::Response => "response.txt".into(),
SaveKind::ReportResults => "results.csv".into(),
SaveKind::ReportBaseline => "report.baseline".into(),
SaveKind::Report => "report.trail".into(),
});
let result_filters = report_result_filters(
std::path::Path::new(¤t)
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default(),
);
let filters: &[super::filepick::Filter] = match kind {
SaveKind::Collection => &[("Hurl", &["hurl"]), ("All files", &["*"])],
SaveKind::Environment(_) => &[("Vars", &["vars"]), ("All files", &["*"])],
SaveKind::ReportResults => &result_filters,
SaveKind::ReportBaseline => &[("Baseline", &["baseline"]), ("All files", &["*"])],
SaveKind::Report => &[("PaperTrail", &["trail"]), ("All files", &["*"])],
SaveKind::Response => &[("All files", &["*"])],
};
app.request_pick(
super::filepick::PickKind::Save {
default_name,
filters: super::filepick::owned_filters(filters),
},
title,
dir.as_deref(),
PickAction::Save(kind),
);
}
fn apply_save(app: &mut GuiApp, kind: SaveKind, path: &Path) -> Result<(), String> {
let path_str = path.to_string_lossy();
if matches!(kind, SaveKind::ReportResults) {
return export_report_results(app, &path_str);
}
if matches!(kind, SaveKind::ReportBaseline) {
return save_report_baseline(app, path);
}
if matches!(kind, SaveKind::Report) {
super::report_editor::save_report_to(app, path)?;
app.session.save();
return Ok(());
}
let content = match kind {
SaveKind::Collection => Some(app.session.collections[app.active_ci()].to_hurl()),
SaveKind::Environment(id) => app
.session
.global_envs
.iter()
.find(|e| e.id == id)
.map(|e| e.to_vars_text()),
SaveKind::Response => Some(app.session.response.lock().unwrap().body.to_string()),
SaveKind::ReportResults | SaveKind::ReportBaseline | SaveKind::Report => None,
};
let text = content.ok_or_else(|| app.strings.gui_nothing_to_save.to_string())?;
std::fs::write(path, text).map_err(|e| format!("{} {e}", app.strings.gui_could_not_write))?;
match kind {
SaveKind::Collection => {
let ci = app.active_ci();
app.session.collections[ci].path = Some(path.to_path_buf());
app.session.collections[ci].mark_saved();
}
SaveKind::Environment(id) => {
if let Some(e) = app.session.global_envs.iter_mut().find(|e| e.id == id) {
e.path = Some(path.to_path_buf());
}
}
SaveKind::Response
| SaveKind::ReportResults
| SaveKind::ReportBaseline
| SaveKind::Report => {}
}
app.session.save();
Ok(())
}
fn export_report_results(app: &mut GuiApp, path: &str) -> Result<(), String> {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let writer = crate::report::writer::writer_for_extension(ext)
.ok_or_else(|| format!("{} {ext}", app.strings.gui_unsupported_format))?;
let ed = app
.report_editor
.as_ref()
.ok_or_else(|| app.strings.gui_nothing_to_save.to_string())?;
let result = ed.result.as_ref().ok_or_else(|| {
if ed.is_running() {
app.strings.report_export_still_running.to_string()
} else {
app.strings.report_export_no_result.to_string()
}
})?;
let header = ed
.flow
.as_ref()
.map(|f| f.header.clone())
.unwrap_or_default();
let bytes = writer.write(result, &header)?;
std::fs::write(path, bytes).map_err(|e| format!("{} {e}", app.strings.gui_could_not_write))?;
if let Some(ed) = app.report_editor.as_mut() {
ed.results_exported = true;
ed.last_export = Some(path.to_string());
}
app.session.status = Some(crate::i18n::Status::ReportExported(path.to_string()));
Ok(())
}
fn save_report_baseline(app: &mut GuiApp, path: &Path) -> Result<(), String> {
let result = app
.report_editor
.as_ref()
.and_then(|e| e.result.as_ref())
.ok_or_else(|| app.strings.report_baseline_no_result.to_string())?;
crate::report::Baseline::from_result(result)
.save(path)
.map_err(|e| format!("{} {e}", app.strings.gui_could_not_write))?;
if let Some(ed) = app.report_editor.as_mut() {
ed.results_exported = true;
}
app.session.status = Some(crate::i18n::Status::ReportBaselineSaved(
path.display().to_string(),
));
Ok(())
}
fn rename_dialog(app: &mut GuiApp, ctx: &egui::Context, target: RenameTarget, mut text: String) {
let title = app.strings.gui_rename;
let lbl_name = app.strings.gui_name;
let lbl_rename = app.strings.gui_rename;
let lbl_cancel = app.strings.gui_cancel;
let frame = modal(ctx, title, |ui| {
let resp = ui.add(
egui::TextEdit::singleline(&mut text)
.desired_width(320.0)
.hint_text(lbl_name),
);
let submit = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let mut keep = true;
let mut go = submit;
ui.horizontal(|ui| {
if ui.button(lbl_rename).clicked() {
go = true;
}
if ui.button(lbl_cancel).clicked() {
keep = false;
}
});
(keep, go)
});
let dismissed = frame.dismissed;
let (keep, submit) = frame.inner_or((true, false));
let keep = keep && !dismissed;
if !keep {
return;
}
if submit {
if !text.trim().is_empty() {
match target {
RenameTarget::Request { ci, idx } => {
if let Some(col) = app.session.collections.get_mut(ci) {
if let Some(entry) = col.entries.get_mut(idx) {
entry.title = text.clone();
entry.modified = true;
}
col.invalidate_request_json();
}
}
RenameTarget::Tab { ci } => {
if let Some(col) = app.session.collections.get_mut(ci) {
col.name = text.clone();
}
}
}
app.session.save();
}
return;
}
app.dialog = Some(Dialog::Rename { target, text });
}
fn prompt_dialog(app: &mut GuiApp, ctx: &egui::Context, kind: PromptKind, mut text: String) {
let title = match &kind {
PromptKind::BaseUrl => app.strings.gui_base_url_title,
PromptKind::NewEnvName => app.strings.gui_new_env_name_title,
PromptKind::NewCollectionName => app.strings.gui_new_collection_name_title,
PromptKind::NewWorkspaceFolder { .. } => app.strings.gui_ws_new_folder_title,
};
let lbl_ok = app.strings.gui_ok;
let lbl_cancel = app.strings.gui_cancel;
let frame = modal(ctx, title, |ui| {
let resp = ui.add(egui::TextEdit::singleline(&mut text).desired_width(360.0));
let submit = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let mut keep = true;
let mut go = submit;
ui.horizontal(|ui| {
if ui.button(lbl_ok).clicked() {
go = true;
}
if ui.button(lbl_cancel).clicked() {
keep = false;
}
});
(keep, go)
});
let dismissed = frame.dismissed;
let (keep, submit) = frame.inner_or((true, false));
let keep = keep && !dismissed;
if !keep {
return;
}
if submit {
match &kind {
PromptKind::BaseUrl => {
app.session.vars.base_url = text.clone();
app.session.save();
}
PromptKind::NewEnvName => {
let name = if text.trim().is_empty() {
app.strings.gui_default_env_name.to_string()
} else {
text.clone()
};
app.session.add_environment(name);
}
PromptKind::NewCollectionName => {
let name = if text.trim().is_empty() {
app.strings.gui_untitled.to_string()
} else {
text.clone()
};
app.session.add_collection(name);
app.session.save();
}
PromptKind::NewWorkspaceFolder { ci, dir } => {
let (ci, dir) = (*ci, dir.clone());
super::requests::new_workspace_folder(app, ci, &dir, text.trim());
}
}
return;
}
app.dialog = Some(Dialog::Prompt { kind, text });
}
fn theme_dialog(app: &mut GuiApp, ctx: &egui::Context, mut state: ThemeEditState) {
enum Action {
Keep,
Cancel,
Apply,
}
let strings = Strings::for_language(&app.session.language);
let title = app.strings.gui_theme_editor_title;
let lbl_name = app.strings.gui_name;
let lbl_apply = app.strings.gui_apply;
let lbl_cancel = app.strings.gui_cancel;
let dim = app.theme.dim;
let frame = modal(ctx, title, |ui| {
ui.horizontal(|ui| {
ui.colored_label(dim, lbl_name);
ui.text_edit_singleline(&mut state.spec.name);
});
ui.separator();
egui::Grid::new("theme_colors")
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
for i in 0..THEME_COLOR_COUNT {
let label = color_label(&strings, i);
ui.colored_label(dim, label);
let mut rgb = state.spec.color(i);
if ui.color_edit_button_srgb(&mut rgb).changed() {
state.spec.set_color(i, rgb);
}
ui.end_row();
}
});
ui.separator();
let mut action = Action::Keep;
ui.horizontal(|ui| {
if ui.button(lbl_apply).clicked() {
action = Action::Apply;
}
if ui.button(lbl_cancel).clicked() {
action = Action::Cancel;
}
});
action
});
let action = if frame.dismissed {
Action::Cancel
} else {
frame.inner_or(Action::Keep)
};
match action {
Action::Cancel => {}
Action::Apply => {
if let Some(existing) = app
.session
.custom_themes
.iter_mut()
.find(|t| t.name == state.original_name)
{
*existing = state.spec.clone();
} else {
app.session.custom_themes.push(state.spec.clone());
}
app.session.active_theme = Some(state.spec.name.clone());
app.session.save();
}
Action::Keep => {
app.dialog = Some(Dialog::Theme(Box::new(state)));
}
}
}
fn file_stem(path: &str) -> String {
std::path::Path::new(path)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "untitled".to_string())
}
fn color_label(s: &Strings, i: usize) -> &'static str {
match i {
0 => s.theme_c_bg,
1 => s.theme_c_panel,
2 => s.theme_c_text,
3 => s.theme_c_dim,
4 => s.theme_c_accent,
5 => s.theme_c_ok,
6 => s.theme_c_err,
7 => s.theme_c_subst,
8 => s.theme_c_pending,
9 => s.theme_c_select_bg,
_ => s.theme_c_select_fg,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::i18n::Language;
use crate::session::Session;
fn app() -> GuiApp {
GuiApp::for_test(Session::default())
}
#[test]
fn an_exported_postman_file_opens_as_whichever_kind_it_turns_out_to_be() {
let dir = std::env::temp_dir().join(format!("pb_gui_import_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let collection = dir.join("api.json");
std::fs::write(
&collection,
r#"{"info":{"name":"Api","schema":"x"},"item":[{"name":"Health","request":{"method":"GET","url":"http://127.0.0.1:8080/health"}}]}"#,
)
.unwrap();
let environment = dir.join("staging.json");
std::fs::write(
&environment,
r#"{"name":"Staging","values":[{"key":"BASE","value":"http://x","enabled":true}]}"#,
)
.unwrap();
let other = dir.join("notes.json");
std::fs::write(&other, r#"{"hello":"world"}"#).unwrap();
let mut app = app();
let tabs = app.session.collections.len();
let envs = app.session.global_envs.len();
assert!(apply_open(&mut app, OpenKind::PostmanExport, &collection).is_ok());
assert_eq!(app.session.collections.len(), tabs + 1);
assert_eq!(app.session.global_envs.len(), envs);
assert!(apply_open(&mut app, OpenKind::PostmanExport, &environment).is_ok());
assert_eq!(app.session.global_envs.len(), envs + 1);
assert_eq!(app.session.collections.len(), tabs + 1);
let err = apply_open(&mut app, OpenKind::PostmanExport, &other).unwrap_err();
assert_eq!(err, app.strings.gui_not_postman_export);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_export_remembers_the_file_it_wrote_so_it_can_be_opened() {
use crate::report::model::{ReportResult, ReportRow};
let mut app = app();
let mut ed = crate::gui::report_editor::ReportEditor::new(
crate::gui::report_editor::ReportOrigin::Session(0),
crate::report::Report::scratch("r"),
);
let mut result = ReportResult::default();
result.column_order = vec!["Time".to_string()];
result.rows.push(ReportRow {
cells: [("Time".to_string(), "100".to_string())]
.into_iter()
.collect(),
key: vec!["a".to_string()],
..Default::default()
});
ed.result = Some(result);
assert!(
ed.last_export.is_none(),
"nothing exported, nothing to open"
);
app.report_editor = Some(ed);
let dir = std::env::temp_dir().join(format!("pb_gui_export_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("r.csv");
let done = export_report_results(&mut app, &path.to_string_lossy());
std::fs::remove_dir_all(&dir).ok();
assert!(done.is_ok(), "the export should succeed: {done:?}");
assert_eq!(
app.report_editor.as_ref().unwrap().last_export.as_deref(),
Some(path.to_string_lossy().as_ref()),
"and the file it wrote is the one Open would hand to the desktop"
);
}
#[test]
fn saving_a_baseline_writes_a_snapshot_that_loads_again() {
use crate::report::model::{ReportResult, ReportRow};
let mut app = app();
let mut ed = crate::gui::report_editor::ReportEditor::new(
crate::gui::report_editor::ReportOrigin::Session(0),
crate::report::Report::scratch("r"),
);
let mut result = ReportResult::default();
result.rows.push(ReportRow {
cells: [("Time".to_string(), "100".to_string())]
.into_iter()
.collect(),
key: vec!["a".to_string()],
..Default::default()
});
ed.result = Some(result);
app.report_editor = Some(ed);
let dir = std::env::temp_dir().join(format!("pb_gui_baseline_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("r.baseline");
let saved = save_report_baseline(&mut app, &path);
let loaded = crate::report::Baseline::load(&path);
std::fs::remove_dir_all(&dir).ok();
assert!(saved.is_ok(), "saving should succeed: {saved:?}");
let loaded = loaded.expect("the snapshot must load back");
assert_eq!(loaded.rows.len(), 1);
assert_eq!(
loaded.rows[0].cells.get("Time").map(String::as_str),
Some("100")
);
assert!(app.report_editor.as_ref().unwrap().results_exported);
}
#[test]
fn opening_a_report_file_loads_it_into_the_editor_and_the_session() {
let mut app = app();
let dir = std::env::temp_dir().join(format!("pb_gui_open_rep_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("nightly.trail");
std::fs::write(&path, "# name: nightly\nREPORT smoke\n").unwrap();
let res = apply_open(&mut app, OpenKind::Report, &path);
let again = apply_open(&mut app, OpenKind::Report, &path);
std::fs::remove_dir_all(&dir).ok();
assert!(res.is_ok() && again.is_ok(), "{res:?} {again:?}");
let ed = app.report_editor.as_ref().expect("editor must be open");
assert_eq!(ed.report.name, "nightly");
assert_eq!(app.session.reports.len(), 1);
assert_eq!(
app.session.reports[0].path.as_deref(),
Some(path.to_string_lossy().as_ref())
);
}
#[test]
fn saving_a_collection_in_place_writes_it_and_clears_the_marker() {
let mut app = app();
let dir = std::env::temp_dir().join(format!("pb_save_active_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("c.hurl");
std::fs::write(
&path,
"GET https://example.test
",
)
.unwrap();
let ci = app.active_ci();
app.session.collections[ci].path = Some(path.clone());
assert!(
save_active_has_path(&app),
"a collection with a file saves without asking"
);
save_active(&mut app);
let on_disk = std::fs::read_to_string(&path).unwrap();
std::fs::remove_dir_all(&dir).ok();
assert!(
!on_disk.contains("example.test"),
"the file was rewritten from the collection: {on_disk:?}"
);
assert!(
matches!(app.session.status, Some(crate::i18n::Status::Saved)),
"and it reported success rather than an error"
);
assert!(
!app.session.collections[ci].has_unsaved_edits(),
"and the collection no longer counts as edited"
);
assert!(
app.pending_pick.is_none(),
"saving in place must not open a file dialog"
);
}
#[test]
fn choosing_an_export_format_renames_the_file() {
assert_eq!(
super::retarget_extension("/tmp/report results.csv", "xlsx"),
"/tmp/report results.xlsx"
);
assert_eq!(
super::retarget_extension("/tmp/run_v4.3.csv", "html"),
"/tmp/run_v4.3.html"
);
assert_eq!(super::retarget_extension("results", "json"), "results.json");
assert_eq!(super::retarget_extension("", "csv"), "results.csv");
}
#[test]
fn the_open_report_is_what_save_saves() {
let mut app = app();
assert_eq!(active_save_kind(&app), SaveKind::Collection);
app.report_editor = Some(crate::gui::report_editor::ReportEditor::new(
crate::gui::report_editor::ReportOrigin::Session(0),
crate::report::Report::scratch("r"),
));
assert_eq!(active_save_kind(&app), SaveKind::Report);
assert!(
!save_active_has_path(&app),
"a scratch report has no file, so Save has to ask where"
);
}
#[test]
fn saving_a_report_writes_its_source_and_adopts_the_path() {
let mut app = app();
let mut report = crate::report::Report::scratch("r");
report.text = "# name: r\nREPORT smoke\n".into();
app.report_editor = Some(crate::gui::report_editor::ReportEditor::new(
crate::gui::report_editor::ReportOrigin::Session(0),
report,
));
app.session
.reports
.push(crate::persistence::PersistedReport {
name: "r".into(),
text: String::new(),
path: None,
git_origin: None,
workspace_root: None,
embedded_active: true,
});
let dir = std::env::temp_dir().join(format!("pb_gui_save_rep_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("r.trail");
let saved = apply_save(&mut app, SaveKind::Report, &path);
let text = std::fs::read_to_string(&path);
std::fs::remove_dir_all(&dir).ok();
assert!(saved.is_ok(), "saving should succeed: {saved:?}");
assert_eq!(text.unwrap(), "# name: r\nREPORT smoke\n");
let ed = app.report_editor.as_ref().unwrap();
assert_eq!(ed.report.path.as_deref(), Some(path.as_path()));
assert!(!ed.report.dirty);
assert_eq!(
app.session.reports[0].path.as_deref(),
Some(path.to_string_lossy().as_ref())
);
}
#[test]
fn saving_a_baseline_without_a_run_is_refused() {
let mut app = app();
app.report_editor = Some(crate::gui::report_editor::ReportEditor::new(
crate::gui::report_editor::ReportOrigin::Session(0),
crate::report::Report::scratch("r"),
));
let path = std::env::temp_dir().join("pb_gui_baseline_never_written.baseline");
let err = save_report_baseline(&mut app, &path).expect_err("no result, no snapshot");
assert_eq!(err, app.strings.report_baseline_no_result);
assert!(!path.exists(), "nothing should have been written");
}
#[test]
fn the_reports_own_output_format_leads_the_export_filters() {
for (ext, expected) in [
("xlsx", "Excel"),
("json", "JSON"),
("html", "HTML"),
("pdf", "PDF"),
] {
let filters = report_result_filters(ext);
assert_eq!(filters[0].0, expected, "{ext} should lead");
assert_eq!(filters.len(), 5, "every format stays available");
}
assert_eq!(report_result_filters("XLSX")[0].0, "Excel");
}
#[test]
fn an_unknown_export_format_leaves_csv_leading() {
for ext in ["", "csv", "docx"] {
let filters = report_result_filters(ext);
assert_eq!(filters[0].0, "CSV", "{ext:?} should leave CSV leading");
assert_eq!(
filters.iter().map(|f| f.0).collect::<Vec<_>>(),
vec!["CSV", "JSON", "HTML", "Excel", "PDF"]
);
}
}
#[test]
fn every_dialog_takes_the_keyboard_with_it_not_just_the_mouse() {
let mut a = app();
assert!(!a.dialog_is_open(), "nothing is open to begin with");
a.dialog = Some(Dialog::Rename {
target: RenameTarget::Tab { ci: 0 },
text: String::new(),
});
assert!(a.dialog_is_open());
a.dialog = None;
a.remote.open_load();
assert!(a.dialog_is_open(), "the git wizard counts too");
a.remote = Default::default();
a.postman.open();
assert!(a.dialog_is_open(), "and the Postman importer");
}
#[test]
fn escape_closes_a_dialog_the_way_its_cancel_button_would() {
let ctx = egui::Context::default();
let mut a = app();
let armed = |a: &mut GuiApp| {
a.dialog = Some(Dialog::Rename {
target: RenameTarget::Tab { ci: 0 },
text: "whatever".to_string(),
});
};
let frame_with = |a: &mut GuiApp, input: egui::RawInput| {
let mut input = input;
input.screen_rect = Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(900.0, 600.0),
));
let _ = ctx.run_ui(input, |ui| show_dialog(a, ui.ctx()));
};
armed(&mut a);
frame_with(&mut a, egui::RawInput::default());
assert!(a.dialog.is_some(), "a quiet frame leaves the dialog open");
let mut esc = egui::RawInput::default();
esc.events.push(egui::Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
});
frame_with(&mut a, esc);
assert!(a.dialog.is_none(), "Escape closes it");
}
fn frame(app: &mut GuiApp, ctx: &egui::Context, input: egui::RawInput) {
let mut input = input;
input.screen_rect = Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(900.0, 600.0),
));
let _ = ctx.run_ui(input, |ui| menu_bar(app, ui));
}
fn alt_held() -> egui::RawInput {
egui::RawInput {
modifiers: egui::Modifiers::ALT,
..Default::default()
}
}
#[test]
fn menu_mnemonics_are_unique_within_each_language() {
for lang in [Language::English, Language::French, Language::Danish] {
let s = crate::i18n::Strings::for_language(&lang);
let keys = [
s.gui_menu_file_key,
s.gui_menu_view_key,
s.gui_menu_settings_key,
];
for k in keys {
assert_eq!(
k.chars().count(),
1,
"{lang:?} mnemonic {k:?} is not a single character"
);
}
let mut seen: Vec<char> = keys.iter().filter_map(|k| mnemonic_char(k)).collect();
seen.sort_unstable();
let before = seen.len();
seen.dedup();
assert_eq!(before, seen.len(), "duplicate mnemonic in {lang:?}");
}
}
#[test]
fn pressing_alt_on_its_own_arms_the_menu_bar_and_a_second_alt_puts_it_away() {
let ctx = egui::Context::default();
let mut a = app();
frame(&mut a, &ctx, alt_held());
assert!(!a.alt_menus.is_armed(), "arming waits for the release");
frame(&mut a, &ctx, egui::RawInput::default());
assert!(a.alt_menus.is_armed());
frame(&mut a, &ctx, alt_held());
frame(&mut a, &ctx, egui::RawInput::default());
assert!(!a.alt_menus.is_armed());
}
#[test]
fn alt_used_as_a_chord_does_not_leave_the_bar_armed() {
let ctx = egui::Context::default();
let mut a = app();
frame(&mut a, &ctx, egui::RawInput::default());
let mut input = alt_held();
input.events.push(egui::Event::Key {
key: egui::Key::F,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::ALT,
});
frame(&mut a, &ctx, input);
frame(&mut a, &ctx, egui::RawInput::default());
assert!(!a.alt_menus.is_armed());
}
#[test]
fn a_mnemonic_opens_its_menu_both_as_a_chord_and_once_armed() {
for chord in [true, false] {
let ctx = egui::Context::default();
let mut a = app();
frame(&mut a, &ctx, egui::RawInput::default());
let file = a.alt_menus.id_for('F').expect("File menu registered");
let mut input = if chord {
alt_held()
} else {
frame(&mut a, &ctx, alt_held());
frame(&mut a, &ctx, egui::RawInput::default());
assert!(a.alt_menus.is_armed());
egui::RawInput::default()
};
let modifiers = if chord {
egui::Modifiers::ALT
} else {
egui::Modifiers::NONE
};
input.events.push(egui::Event::Key {
key: egui::Key::F,
physical_key: None,
pressed: true,
repeat: false,
modifiers,
});
frame(&mut a, &ctx, input);
assert!(
egui::Popup::is_id_open(&ctx, file.with("popup")),
"chord={chord}: File menu should be open"
);
assert!(!a.alt_menus.is_armed(), "chord={chord}: opening disarms");
}
}
#[test]
fn escape_cancels_an_armed_menu_bar() {
let ctx = egui::Context::default();
let mut a = app();
frame(&mut a, &ctx, alt_held());
frame(&mut a, &ctx, egui::RawInput::default());
assert!(a.alt_menus.is_armed());
let mut input = egui::RawInput::default();
input.events.push(egui::Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
});
frame(&mut a, &ctx, input);
assert!(!a.alt_menus.is_armed());
}
#[test]
fn the_mnemonic_is_underlined_only_while_the_bar_is_armed() {
let ctx = egui::Context::default();
let underlined = |armed: bool| {
let mut found = Vec::new();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let text = menu_title(ui, "Settings", 'S', armed);
found = match text {
egui::WidgetText::LayoutJob(job) => job
.sections
.iter()
.filter(|s| s.format.underline != egui::Stroke::NONE)
.map(|s| {
job.text[usize::from(s.byte_range.start)..usize::from(s.byte_range.end)]
.to_string()
})
.collect::<Vec<_>>(),
_ => Vec::new(),
};
});
found
};
assert_eq!(underlined(false), Vec::<String>::new());
assert_eq!(underlined(true), vec!["S".to_string()]);
}
}