use std::collections::{HashMap, HashSet};
use eframe::egui::text::LayoutJob;
use eframe::egui::{self, Color32, FontId, RichText, TextFormat};
use crate::hurl::{FormField, FormFieldKind, HurlEntry, KvRow};
use crate::i18n::Strings;
use crate::request::{SubstInfo, SubstKind, apply_request_json, build_request_json};
use super::app::{EditorSection, GuiApp};
use super::theme::GuiTheme;
use super::widgets;
const SHADOW_ICON: &str = "!";
#[derive(Default)]
struct SubstSeen {
loaded: bool,
literal: bool,
pending: bool,
failed: bool,
undefined: bool,
computed: bool,
shadowed: bool,
}
impl SubstSeen {
fn mark(&mut self, kind: SubstKind) {
match kind {
SubstKind::Loaded => self.loaded = true,
SubstKind::Computed => self.computed = true,
SubstKind::Literal => self.literal = true,
SubstKind::Pending => self.pending = true,
SubstKind::Failed => self.failed = true,
SubstKind::Undefined => self.undefined = true,
}
}
fn any(&self) -> bool {
self.loaded
|| self.literal
|| self.pending
|| self.failed
|| self.undefined
|| self.computed
}
}
fn subst_color(kind: SubstKind, th: &GuiTheme) -> Color32 {
match kind {
SubstKind::Literal => th.subst,
SubstKind::Loaded => th.ok,
SubstKind::Computed => th.computed,
SubstKind::Pending => th.pending,
SubstKind::Failed => th.err,
SubstKind::Undefined => th.err,
}
}
fn subst_legend(ui: &mut egui::Ui, seen: &SubstSeen, th: &GuiTheme, s: &Strings) {
if !seen.any() {
return;
}
ui.horizontal_wrapped(|ui| {
for (present, word, color) in [
(seen.loaded, s.subst_hint_loaded, th.ok),
(seen.literal, s.subst_hint_literal, th.subst),
(seen.pending, s.subst_hint_loading, th.pending),
(seen.failed, s.subst_hint_missing, th.err),
(seen.undefined, s.subst_hint_undefined, th.err),
(seen.computed, s.subst_hint_generated, th.computed),
] {
if present {
ui.colored_label(color, format!("\u{25cf} {word}"));
}
}
if seen.shadowed {
ui.colored_label(
th.pending,
format!("{SHADOW_ICON} {}", s.subst_hint_shadowed),
);
}
});
}
fn highlight_code_editable(
text: &str,
vars: &HashMap<String, SubstInfo>,
shadowed: &HashSet<String>,
th: &GuiTheme,
font: FontId,
seen: &mut SubstSeen,
) -> LayoutJob {
let fmt = |color: Color32| TextFormat::simple(font.clone(), color);
let mut job = LayoutJob::default();
let mut rest = text;
while let Some(open) = rest.find("{{") {
let Some(close_rel) = rest[open + 2..].find("}}") else {
break;
};
let close = open + 2 + close_rel;
let end = close + 2;
let inner = rest[open + 2..close].trim();
if open > 0 {
job.append(&rest[..open], 0.0, fmt(th.text));
}
let token = &rest[open..end];
match vars.get(inner) {
Some(info) => {
seen.mark(info.kind);
if shadowed.contains(inner) {
seen.shadowed = true;
}
job.append(token, 0.0, fmt(subst_color(info.kind, th)));
}
None => {
seen.mark(SubstKind::Undefined);
job.append(token, 0.0, fmt(subst_color(SubstKind::Undefined, th)));
}
}
rest = &rest[end..];
}
if !rest.is_empty() {
job.append(rest, 0.0, fmt(th.text));
}
job
}
fn substitution_statuses(
text: &str,
vars: &HashMap<String, SubstInfo>,
shadowed: &HashSet<String>,
) -> SubstSeen {
let mut seen = SubstSeen::default();
let mut rest = text;
while let Some(open) = rest.find("{{") {
let Some(close_rel) = rest[open + 2..].find("}}") else {
break;
};
let close = open + 2 + close_rel;
let inner = rest[open + 2..close].trim();
match vars.get(inner) {
Some(info) => {
seen.mark(info.kind);
if shadowed.contains(inner) {
seen.shadowed = true;
}
}
None => seen.mark(SubstKind::Undefined),
}
rest = &rest[close + 2..];
}
seen
}
fn cached_code_job(
ui: &egui::Ui,
text: &str,
vars: &HashMap<String, SubstInfo>,
shadowed: &HashSet<String>,
th: &GuiTheme,
font: FontId,
) -> LayoutJob {
use std::hash::{Hash, Hasher};
let id = egui::Id::new("code_edit_highlight");
let mut h = std::collections::hash_map::DefaultHasher::new();
crate::gui::report_editor::fnv1a(text.as_bytes(), crate::gui::report_editor::FNV_OFFSET)
.hash(&mut h);
font.size.to_bits().hash(&mut h);
let mut refs = 0u64;
for (k, info) in vars {
refs ^= crate::gui::report_editor::fnv1a(
k.as_bytes(),
crate::gui::report_editor::FNV_OFFSET ^ info.kind as u64,
);
}
for k in shadowed {
refs ^= crate::gui::report_editor::fnv1a(k.as_bytes(), 0x9e37_79b9_7f4a_7c15);
}
refs.hash(&mut h);
format!("{:?}", (th.text, th.subst, th.pending, th.err, th.ok)).hash(&mut h);
let key = h.finish();
if let Some((cached_key, job)) = ui.data(|d| d.get_temp::<(u64, LayoutJob)>(id))
&& cached_key == key
{
return job;
}
let mut ignored = SubstSeen::default();
let job = highlight_code_editable(text, vars, shadowed, th, font, &mut ignored);
ui.data_mut(|d| d.insert_temp(id, (key, job.clone())));
job
}
fn apply_code_edit(
session: &mut crate::session::Session,
code_edit: &mut super::app::CodeEdit,
strings: &Strings,
ci: usize,
sel: usize,
show_hurl: bool,
text: &str,
) -> bool {
if show_hurl {
let entries = crate::hurl::parse_hurl(text);
if entries.len() == 1 {
let mut parsed = entries.into_iter().next().unwrap();
let entry = &mut session.collections[ci].entries[sel];
parsed.user_added = entry.user_added;
parsed.baseline = entry.baseline.take();
parsed.uid = entry.uid;
parsed.last_run = std::mem::take(&mut entry.last_run);
parsed.last_response = entry.last_response.take();
*entry = parsed;
code_edit.error = None;
true
} else {
code_edit.error = Some(
crate::hurl::parse_hurl_error(text)
.unwrap_or_else(|| strings.gui_code_parse_error.to_string()),
);
false
}
} else {
let base = session.collections[ci].entries[sel].clone();
match apply_request_json(&base, text) {
Ok(parsed) => {
session.collections[ci].entries[sel] = parsed;
code_edit.error = None;
true
}
Err(e) => {
code_edit.error = Some(e);
false
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_code_section(
app: &mut GuiApp,
ui: &mut egui::Ui,
theme: &GuiTheme,
ci: usize,
sel: usize,
code_show_hurl: &mut bool,
subst_vars: &HashMap<String, SubstInfo>,
shadowed: &HashSet<String>,
) -> bool {
let mut changed = false;
let (lbl_json, lbl_hurl) = (
app.strings.gui_code_repr_json,
app.strings.gui_code_repr_hurl,
);
ui.horizontal(|ui| {
if widgets::selectable(ui, !*code_show_hurl, lbl_json).clicked() {
*code_show_hurl = false;
app.code_edit.key = None;
}
if widgets::selectable(ui, *code_show_hurl, lbl_hurl).clicked() {
*code_show_hurl = true;
app.code_edit.key = None;
}
});
ui.add_space(4.0);
let key = (ci, sel, *code_show_hurl);
if app.code_edit.key != Some(key) {
let entry = &app.session.collections[ci].entries[sel];
app.code_edit.buf = if *code_show_hurl {
entry.to_hurl()
} else {
build_request_json(entry)
};
app.code_edit.key = Some(key);
app.code_edit.error = None;
}
let seen = substitution_statuses(&app.code_edit.buf, subst_vars, shadowed);
let row_h = ui.text_style_height(&egui::TextStyle::Monospace);
let reserved = 44.0
+ if app.code_edit.error.is_some() {
24.0
} else {
0.0
};
let editor_h = (ui.available_height() - reserved).max(row_h * 6.0);
let rows = (editor_h / row_h).floor().max(6.0) as usize;
let subst_vars_l = subst_vars;
let shadowed_l = shadowed;
let theme_l = theme;
let mut layouter = |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap: f32| {
let font = egui::TextStyle::Monospace.resolve(ui.style());
let mut job = cached_code_job(ui, buf.as_str(), subst_vars_l, shadowed_l, theme_l, font);
job.wrap.max_width = wrap;
ui.fonts_mut(|f| f.layout_job(job))
};
let resp = egui::ScrollArea::vertical()
.max_height(editor_h)
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut app.code_edit.buf)
.code_editor()
.desired_width(f32::INFINITY)
.desired_rows(rows)
.layouter(&mut layouter),
)
});
if resp.inner.changed() {
let text = app.code_edit.buf.clone();
if apply_code_edit(
&mut app.session,
&mut app.code_edit,
&app.strings,
ci,
sel,
*code_show_hurl,
&text,
) {
changed = true;
}
}
ui.add_space(4.0);
if let Some(err) = &app.code_edit.error {
ui.colored_label(theme.err, format!("\u{26a0} {err}"));
}
subst_legend(ui, &seen, theme, &app.strings);
changed
}
pub fn ui(app: &mut GuiApp, ui: &mut egui::Ui) {
let ci = app.active_ci();
let theme = app.theme;
if app.session.collections[ci].entries.is_empty() {
let no_requests = app.strings.gui_no_requests_editor;
let new_request_btn = format!("{} {}", super::icons::PLUS, app.strings.gui_new_request_btn);
let new_request_title = app.strings.gui_new_request;
ui.vertical_centered(|ui| {
ui.add_space(30.0);
ui.colored_label(theme.dim, no_requests);
if ui.button(new_request_btn).clicked() {
let mut e = HurlEntry::default();
e.method = "GET".into();
e.url = app.session.vars.base_url.clone();
e.title = new_request_title.into();
e.user_added = true;
let col = &mut app.session.collections[ci];
col.entries.push(e);
col.selected_entry = 0;
col.invalidate_request_json();
}
});
return;
}
let sel = app.session.collections[ci]
.selected_entry
.min(app.session.collections[ci].entries.len() - 1);
if app.session.collections[ci].entries[sel].is_unreadable() {
let msg = app.strings.cannot_edit_unreadable;
ui.add_space(6.0);
ui.colored_label(theme.err, msg);
ui.add_space(6.0);
let mut raw = app.session.collections[ci].entries[sel]
.unparsed
.clone()
.unwrap_or_default();
let resp = ui.add(
egui::TextEdit::multiline(&mut raw)
.code_editor()
.desired_width(f32::INFINITY),
);
if resp.changed() {
let entries = crate::hurl::parse_hurl(&raw);
let col = &mut app.session.collections[ci];
match &entries[..] {
[only] if !only.is_unreadable() => {
let mut healed = only.clone();
healed.baseline = col.entries[sel].baseline.clone();
healed.mark_edited();
col.entries[sel] = healed;
}
_ => col.entries[sel].unparsed = Some(raw),
}
col.invalidate_request_json();
}
return;
}
let mut changed = false;
let mut send = false;
let mut extract: Option<PendingExtract> = None;
let ex_label = app.strings.gui_extract_parameter;
let section = app.editor_section;
let mut code_show_hurl = app.show_hurl;
let send_label = format!("{} {}", app.strings.gui_send, super::icons::PLAY);
let app_strings_send_tooltip = app.strings.gui_send_tooltip;
{
let entry = &mut app.session.collections[ci].entries[sel];
let name_label = app.strings.gui_name;
let url_hint = app.strings.gui_hint_url;
ui.horizontal(|ui| {
ui.label(RichText::new(name_label).color(theme.dim));
let name = widgets::flat_fields(ui, |ui| {
ui.add(
egui::TextEdit::singleline(&mut entry.title)
.desired_width(f32::INFINITY)
.hint_text(name_label),
)
});
if name.changed() {
changed = true;
}
});
ui.add_space(2.0);
ui.horizontal(|ui| {
if widgets::method_combo(ui, &theme, "method", &mut entry.method) {
changed = true;
}
let send_w = 92.0;
let url = widgets::wrapping_field_font(
ui,
(ui.available_width() - send_w).max(80.0),
&mut entry.url,
url_hint,
theme.text,
egui::TextStyle::Monospace,
);
if url.changed() {
changed = true;
}
extract_menu(
&url,
ex_label,
ExtractTarget::Url,
&entry.url,
true,
&mut extract,
);
let btn = ui
.add_sized(
[80.0, 24.0],
egui::Button::new(RichText::new(send_label).strong().color(theme.select_fg))
.fill(theme.accent),
)
.on_hover_text(app_strings_send_tooltip);
if btn.clicked() {
send = true;
}
});
}
ui.add_space(4.0);
{
let entry = &app.session.collections[ci].entries[sel];
let params_n = entry.queries.len();
let headers_n = entry.headers.len();
let cookies_n = entry.cookies.len();
let options_n = entry.options.len();
let asserts_n = entry.asserts.len();
let captures_n = entry.captures.len();
let computed_n = entry.generators.len();
let has_body = entry
.body_src
.as_ref()
.map(|b| !b.is_empty())
.unwrap_or(false)
|| !entry.form_fields.is_empty();
let has_auth = entry.basic_auth.is_some();
let mut cur = app.editor_section;
let st = &app.strings;
let tabs = [
(EditorSection::All, st.tab_all.to_string()),
(
EditorSection::Params,
format!("{}{}", st.gui_sec_params, widgets::count_suffix(params_n)),
),
(
EditorSection::Headers,
format!("{}{}", st.gui_sec_headers, widgets::count_suffix(headers_n)),
),
(
EditorSection::Body,
format!("{}{}", st.gui_sec_body, if has_body { " •" } else { "" }),
),
(
EditorSection::Auth,
format!("{}{}", st.gui_sec_auth, if has_auth { " •" } else { "" }),
),
(
EditorSection::Cookies,
format!("{}{}", st.gui_sec_cookies, widgets::count_suffix(cookies_n)),
),
(
EditorSection::Options,
format!("{}{}", st.gui_sec_options, widgets::count_suffix(options_n)),
),
(
EditorSection::Asserts,
format!("{}{}", st.gui_sec_asserts, widgets::count_suffix(asserts_n)),
),
(
EditorSection::Captures,
format!(
"{}{}",
st.gui_sec_captures,
widgets::count_suffix(captures_n)
),
),
(
EditorSection::Computed,
format!(
"{}{}",
st.gui_sec_generated,
widgets::count_suffix(computed_n)
),
),
(EditorSection::Code, st.gui_sec_code.to_string()),
];
ui.horizontal_wrapped(|ui| {
for (value, label) in &tabs {
let selected = cur == *value;
let mut text = RichText::new(label);
text = if selected {
text.strong().color(theme.text)
} else {
text.color(theme.dim)
};
if super::widgets::selectable(ui, selected, text).clicked() {
cur = *value;
}
}
});
app.editor_section = cur;
}
ui.separator();
let gen_vars: Vec<String> = if matches!(section, EditorSection::Computed | EditorSection::All) {
let env = app.session.effective_env(ci);
let mut names: Vec<String> =
crate::request::subst_map(&app.session.collections[ci], env.as_ref())
.into_keys()
.collect();
let own: HashSet<String> = app.session.collections[ci].entries[sel]
.generators
.iter()
.map(|(n, _)| n.trim().to_string())
.collect();
names.retain(|n| !own.contains(n));
names.sort();
names
} else {
Vec::new()
};
let (subst_vars, shadowed) = if section == EditorSection::Code {
let env = app.session.effective_env(ci);
(
crate::request::subst_map(&app.session.collections[ci], env.as_ref()),
app.session.shadowed_env_keys(ci),
)
} else {
(HashMap::new(), HashSet::new())
};
if section != EditorSection::Code {
app.code_edit.key = None;
}
if section == EditorSection::Code {
if draw_code_section(
app,
ui,
&theme,
ci,
sel,
&mut code_show_hurl,
&subst_vars,
&shadowed,
) {
changed = true;
}
} else {
let mut browse: Option<usize> = None;
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
let entry = &mut app.session.collections[ci].entries[sel];
let st = &app.strings;
match section {
EditorSection::All => {
const STACK: [EditorSection; 9] = [
EditorSection::Params,
EditorSection::Headers,
EditorSection::Body,
EditorSection::Auth,
EditorSection::Cookies,
EditorSection::Options,
EditorSection::Asserts,
EditorSection::Captures,
EditorSection::Computed,
];
for (i, sec) in STACK.iter().enumerate() {
if i > 0 {
ui.add_space(8.0);
ui.separator();
}
ui.label(
RichText::new(section_title(*sec, st))
.strong()
.color(theme.text),
);
if draw_section(
*sec,
ui,
&theme,
st,
entry,
&gen_vars,
&mut browse,
&mut extract,
) {
changed = true;
}
}
}
other => {
if draw_section(
other,
ui,
&theme,
st,
entry,
&gen_vars,
&mut browse,
&mut extract,
) {
changed = true;
}
}
}
});
if let Some(field) = browse {
let seed = app.session.collections[ci].entries[sel]
.form_fields
.get(field)
.and_then(|f| super::filepick::seed_dir(&f.value))
.or_else(|| {
app.session
.picker_dir(crate::session::PickerKind::Other)
.map(|p| p.to_path_buf())
});
app.request_pick(
super::filepick::PickKind::File {
filters: Vec::new(),
},
app.strings.gui_browse,
seed.as_deref(),
super::menu::PickAction::FormFieldFile {
ci,
entry: sel,
field,
},
);
}
}
if let Some(p) = extract {
let declared = app.session.collections[ci].entries[sel].variable_defaults();
let name = crate::hurl::suggest_parameter_name(&p.value, &declared);
app.dialog = Some(super::app::Dialog::ExtractParameter {
ci,
entry: sel,
target: p.target,
value: p.value,
range: p.range,
name,
});
}
app.show_hurl = code_show_hurl;
if changed {
let col = &mut app.session.collections[ci];
col.entries[sel].mark_edited();
col.invalidate_request_json();
}
if send {
app.session.collections[ci].selected_entry = sel;
app.run_active();
}
}
fn conflict_notice(ui: &mut egui::Ui, theme: &super::theme::GuiTheme, st: &Strings) -> bool {
let mut clear = false;
egui::Frame::new()
.fill(theme.panel)
.stroke(egui::Stroke::new(1.0, theme.err))
.inner_margin(6.0)
.corner_radius(4.0)
.show(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.label(
RichText::new(st.gui_body_conflict_headline)
.color(theme.err)
.strong(),
);
ui.label(RichText::new(st.gui_body_conflict_detail).color(theme.text));
if ui.button(st.gui_body_conflict_clear).clicked() {
clear = true;
}
});
clear
}
fn stale_notes_notice(
ui: &mut egui::Ui,
theme: &super::theme::GuiTheme,
st: &Strings,
can_adopt: bool,
) -> Option<bool> {
let mut choice = None;
egui::Frame::new()
.fill(theme.panel)
.stroke(egui::Stroke::new(1.0, theme.pending))
.inner_margin(6.0)
.corner_radius(4.0)
.show(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.label(
RichText::new(st.gui_notes_stale_headline)
.color(theme.pending)
.strong(),
);
ui.label(RichText::new(st.gui_notes_stale_detail).color(theme.text));
ui.horizontal(|ui| {
let adopt = ui.add_enabled(
can_adopt,
egui::Button::new(if can_adopt {
st.notes_stale_adopt
} else {
st.notes_stale_adopt_blocked
}),
);
if adopt.clicked() {
choice = Some(true);
}
if ui.button(st.notes_stale_discard).clicked() {
choice = Some(false);
}
});
});
choice
}
fn section_title(section: EditorSection, s: &Strings) -> &'static str {
match section {
EditorSection::All => s.tab_all,
EditorSection::Params => s.gui_sec_params,
EditorSection::Headers => s.gui_sec_headers,
EditorSection::Body => s.gui_sec_body,
EditorSection::Auth => s.gui_sec_auth,
EditorSection::Cookies => s.gui_sec_cookies,
EditorSection::Options => s.gui_sec_options,
EditorSection::Asserts => s.gui_sec_asserts,
EditorSection::Captures => s.gui_sec_captures,
EditorSection::Computed => s.gui_sec_generated,
EditorSection::Code => s.gui_sec_code,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExtractTarget {
Url,
Body,
FormField(usize),
Kv(KvSectionKind, usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum KvSectionKind {
Query,
Header,
Cookie,
Option,
}
pub(super) struct PendingExtract {
pub target: ExtractTarget,
pub value: String,
pub range: Option<std::ops::Range<usize>>,
}
fn extract_menu(
resp: &egui::Response,
label: &str,
target: ExtractTarget,
full: &str,
selectable: bool,
out: &mut Option<PendingExtract>,
) {
resp.context_menu(|ui| {
if ui.button(label).clicked() {
let range = selectable
.then(|| egui::TextEdit::load_state(ui.ctx(), resp.id))
.flatten()
.and_then(|st| st.cursor.char_range())
.map(|r| r.as_sorted_char_range())
.and_then(|r| char_range_to_bytes(full, r.start.0..r.end.0))
.filter(|r| !full[r.clone()].trim().is_empty());
let value = match &range {
Some(r) => full[r.clone()].to_string(),
None => full.to_string(),
};
if !value.trim().is_empty() {
*out = Some(PendingExtract {
target,
value,
range,
});
}
ui.close();
}
});
}
fn kv_extract(
kind: KvSectionKind,
row: Option<usize>,
rows: &[KvRow],
out: &mut Option<PendingExtract>,
) {
let Some(i) = row else { return };
let Some(r) = rows.get(i) else { return };
if r.value.trim().is_empty() {
return;
}
*out = Some(PendingExtract {
target: ExtractTarget::Kv(kind, i),
value: r.value.clone(),
range: None,
});
}
fn char_range_to_bytes(
text: &str,
chars: std::ops::Range<usize>,
) -> Option<std::ops::Range<usize>> {
if chars.start >= chars.end {
return None;
}
let mut it = text
.char_indices()
.map(|(i, _)| i)
.chain(std::iter::once(text.len()));
let start = it.by_ref().nth(chars.start)?;
let end = it.nth(chars.end - chars.start - 1)?;
Some(start..end)
}
fn draw_section(
section: EditorSection,
ui: &mut egui::Ui,
theme: &super::theme::GuiTheme,
st: &Strings,
entry: &mut HurlEntry,
vars: &[String],
browse: &mut Option<usize>,
extract: &mut Option<PendingExtract>,
) -> bool {
let mut changed = false;
let ex_label = st.gui_extract_parameter;
let req = egui::Id::new((
"req",
entry.uid,
entry.title.as_str(),
entry.method.as_str(),
entry.url.as_str(),
));
match section {
EditorSection::All | EditorSection::Code => {}
EditorSection::Params => {
ui.label(RichText::new(st.gui_query_parameters).color(theme.dim));
let mut hit = None;
if widgets::kv_editor(
ui,
theme,
st,
("params", req),
&mut entry.queries,
st.gui_hint_key,
st.gui_hint_value,
st.hdr_key,
st.hdr_value,
ex_label,
&mut hit,
&[],
) {
changed = true;
}
kv_extract(KvSectionKind::Query, hit, &entry.queries, extract);
}
EditorSection::Headers => {
let mut hit = None;
if widgets::kv_editor(
ui,
theme,
st,
("headers", req),
&mut entry.headers,
st.gui_hint_header,
st.gui_hint_value,
st.gui_hint_header,
st.hdr_value,
ex_label,
&mut hit,
crate::http::COMMON_HEADERS,
) {
changed = true;
}
kv_extract(KvSectionKind::Header, hit, &entry.headers, extract);
}
EditorSection::Body => {
let id = egui::Id::new((
"body_mode",
entry.title.as_str(),
entry.method.as_str(),
entry.url.as_str(),
));
let default_form = !entry.form_fields.is_empty();
let mut form_mode =
ui.data_mut(|d| *d.get_temp_mut_or_insert_with(id, || default_form));
ui.horizontal(|ui| {
if super::widgets::selectable(ui, !form_mode, st.gui_body_mode_raw).clicked() {
form_mode = false;
}
if super::widgets::selectable(ui, form_mode, st.gui_body_mode_form).clicked() {
form_mode = true;
}
});
ui.data_mut(|d| d.insert_temp(id, form_mode));
if entry.body_form_conflict() {
let cleared = conflict_notice(ui, theme, st);
if cleared {
entry.body_src = None;
changed = true;
}
ui.add_space(4.0);
}
if entry.stale_body_notes().is_some() {
match stale_notes_notice(ui, theme, st, entry.can_adopt_body_notes()) {
Some(true) => changed |= entry.adopt_body_notes(),
Some(false) => changed |= entry.discard_body_notes(),
None => {}
}
ui.add_space(4.0);
}
if form_mode {
if form_editor(
ui,
theme,
st,
&mut entry.form_fields,
browse,
ex_label,
extract,
) {
changed = true;
}
} else {
let mut body = entry.body_src.take().unwrap_or_default();
let resp = ui.add(
egui::TextEdit::multiline(&mut body)
.code_editor()
.desired_rows(10)
.desired_width(f32::INFINITY)
.hint_text(st.gui_raw_body_hint),
);
if resp.changed() {
changed = true;
}
extract_menu(&resp, ex_label, ExtractTarget::Body, &body, true, extract);
entry.body_src = if body.is_empty() { None } else { Some(body) };
}
}
EditorSection::Auth => {
let mut enabled = entry.basic_auth.is_some();
if ui.checkbox(&mut enabled, st.gui_basic_auth).changed() {
entry.basic_auth = if enabled {
Some((String::new(), String::new()))
} else {
None
};
changed = true;
}
if let Some((user, pass)) = entry.basic_auth.as_mut() {
egui::Grid::new("auth").num_columns(2).show(ui, |ui| {
ui.label(st.gui_username);
if ui.text_edit_singleline(user).changed() {
changed = true;
}
ui.end_row();
ui.label(st.gui_password);
if ui
.add(egui::TextEdit::singleline(pass).password(true))
.changed()
{
changed = true;
}
ui.end_row();
});
}
}
EditorSection::Cookies => {
let mut hit = None;
if widgets::kv_editor(
ui,
theme,
st,
("cookies", req),
&mut entry.cookies,
st.gui_hint_name,
st.gui_hint_value,
st.hdr_name,
st.hdr_value,
ex_label,
&mut hit,
&[],
) {
changed = true;
}
kv_extract(KvSectionKind::Cookie, hit, &entry.cookies, extract);
}
EditorSection::Options => {
ui.label(RichText::new(st.gui_per_request_options).color(theme.dim));
let params = entry.variable_defaults();
let note = if params.is_empty() {
st.gui_options_declare_parameter.to_string()
} else {
let names = params
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>()
.join(", ");
crate::i18n::fill(st.gui_options_parameters, &[&names])
};
ui.label(RichText::new(note).color(theme.dim));
let mut hit = None;
if widgets::kv_editor(
ui,
theme,
st,
("options", req),
&mut entry.options,
st.gui_hint_option,
st.gui_hint_value,
st.hdr_option,
st.hdr_value,
ex_label,
&mut hit,
&[],
) {
changed = true;
}
kv_extract(KvSectionKind::Option, hit, &entry.options, extract);
}
EditorSection::Asserts => {
ui.label(RichText::new(st.gui_response_assertions).color(theme.dim));
if assert_editor(ui, theme, st, &mut entry.asserts) {
changed = true;
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(st.gui_expected_status);
let mut s = entry
.expected_status
.map(|v| v.to_string())
.unwrap_or_default();
if ui
.add(egui::TextEdit::singleline(&mut s).desired_width(60.0))
.changed()
{
entry.expected_status = s.trim().parse::<u16>().ok();
changed = true;
}
});
}
EditorSection::Captures => {
ui.label(RichText::new(st.gui_captures_help).color(theme.dim));
if widgets::pair_editor(
ui,
theme,
st,
("captures", req),
&mut entry.captures,
st.gui_hint_name,
st.gui_hint_query,
st.hdr_name,
st.hdr_query,
) {
changed = true;
}
}
EditorSection::Computed => {
ui.label(RichText::new(st.gui_generated_help).color(theme.dim));
if widgets::computed_editor(ui, theme, st, req, &mut entry.generators, vars) {
changed = true;
}
}
}
changed
}
fn assert_editor(
ui: &mut egui::Ui,
theme: &super::theme::GuiTheme,
s: &Strings,
asserts: &mut Vec<String>,
) -> bool {
let mut changed = false;
let mut remove = None;
let x_w = widgets::remove_width(ui);
let row_h = ui.spacing().interact_size.y;
widgets::table_rows(ui, |ui| {
for i in 0..asserts.len() {
widgets::table_row(ui, |ui| {
let val_w = (ui.available_width() - x_w - 8.0).max(40.0);
let r = widgets::wrapping_field_font(
ui,
val_w,
&mut asserts[i],
s.gui_hint_assert,
theme.text,
egui::TextStyle::Monospace,
);
if r.changed() {
changed = true;
}
let hit = widgets::flat_buttons(ui, |ui| {
ui.add_sized(
[x_w, row_h],
egui::Button::new(RichText::new(super::icons::CLOSE).color(theme.err)),
)
});
if hit.clicked() {
remove = Some(i);
}
});
}
});
if let Some(i) = remove {
asserts.remove(i);
changed = true;
}
if ui.button(s.gui_add_assert).clicked() {
asserts.push(String::new());
changed = true;
}
changed
}
fn form_editor(
ui: &mut egui::Ui,
theme: &super::theme::GuiTheme,
s: &Strings,
fields: &mut Vec<FormField>,
browse: &mut Option<usize>,
ex_label: &str,
extract: &mut Option<PendingExtract>,
) -> bool {
let mut changed = false;
let mut remove = None;
let key_w = super::widgets::split_key_width(ui, 160.0);
let kind_w = 80.0;
let check_w = ui.spacing().interact_size.y + 4.0;
let x_w = super::widgets::remove_width(ui);
let row_h = ui.spacing().interact_size.y;
let browse_w = super::widgets::button_width(ui, s.gui_browse);
super::widgets::table_rows(ui, |ui| {
for i in 0..fields.len() {
super::widgets::table_row(ui, |ui| {
if ui.checkbox(&mut fields[i].enabled, "").changed() {
changed = true;
}
let row_color = if fields[i].enabled {
theme.text
} else {
theme.dim
};
if super::widgets::sized_key(
ui,
key_w,
&mut fields[i].key,
s.gui_hint_field,
row_color,
)
.changed()
{
changed = true;
}
let mut kind = fields[i].kind;
egui::ComboBox::from_id_salt(("formkind", i))
.selected_text(match kind {
FormFieldKind::Text => s.gui_kind_text,
FormFieldKind::File => s.gui_kind_file,
FormFieldKind::Base64File => s.gui_kind_base64,
})
.width(kind_w)
.show_ui(ui, |ui| {
for (k, label) in [
(FormFieldKind::Text, s.gui_kind_text),
(FormFieldKind::File, s.gui_kind_file),
(FormFieldKind::Base64File, s.gui_kind_base64),
] {
if super::widgets::selectable(ui, kind == k, label).clicked() {
kind = k;
changed = true;
}
}
});
fields[i].kind = kind;
let is_file = matches!(kind, FormFieldKind::File | FormFieldKind::Base64File);
let hint = match kind {
FormFieldKind::Text => s.gui_hint_value,
_ => s.gui_hint_file_path,
};
let mut spare = ui.available_width() - x_w - 8.0;
if is_file {
spare -= browse_w + 8.0;
}
let val = super::widgets::wrapping_field(
ui,
spare.max(40.0),
&mut fields[i].value,
hint,
row_color,
);
if val.changed() {
changed = true;
}
extract_menu(
&val,
ex_label,
ExtractTarget::FormField(i),
&fields[i].value,
false,
extract,
);
super::widgets::flat_buttons(ui, |ui| {
if is_file
&& ui
.add_sized([browse_w, row_h], egui::Button::new(s.gui_browse))
.clicked()
{
*browse = Some(i);
}
if ui
.add_sized(
[x_w, row_h],
egui::Button::new(RichText::new(super::icons::CLOSE).color(theme.err)),
)
.clicked()
{
remove = Some(i);
}
});
});
if fields[i].kind == FormFieldKind::Base64File {
super::widgets::table_row(ui, |ui| {
ui.add_space(check_w);
ui.add_sized(
[key_w, row_h],
egui::Label::new(
RichText::new(s.gui_base64_prefix).color(theme.dim).small(),
),
);
ui.add_space(kind_w);
let mut prefix = fields[i].base64_prefix.clone().unwrap_or_default();
let w = (ui.available_width() - x_w - 8.0).max(40.0);
if super::widgets::wrapping_field(ui, w, &mut prefix, "", theme.text).changed()
{
fields[i].base64_prefix = if prefix.is_empty() {
None
} else {
Some(prefix)
};
changed = true;
}
});
}
}
});
if let Some(i) = remove {
fields.remove(i);
changed = true;
}
if ui.button(s.gui_add_field).clicked() {
fields.push(FormField {
enabled: true,
..Default::default()
});
changed = true;
}
changed
}
#[cfg(test)]
mod tests {
use super::*;
use crate::i18n::Language;
use crate::session::Session;
use eframe::egui::FontId;
fn session_with_entry() -> Session {
let mut s = Session::default();
let mut e = HurlEntry::default();
e.method = "GET".into();
e.url = "https://example.com/api".into();
e.title = "Demo".into();
s.collections[0].entries = vec![e];
s.collections[0].selected_entry = 0;
s
}
fn painted(shapes: &[egui::epaint::ClippedShape]) -> Vec<String> {
fn walk(shape: &egui::epaint::Shape, out: &mut Vec<String>) {
match shape {
egui::epaint::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut out = Vec::new();
for c in shapes {
walk(&c.shape, &mut out);
}
out
}
fn entry_with_stale_notes() -> HurlEntry {
let text = "POST http://h/a\n\
# [Body] 3\n\
# {\n\
# \"a\": 1 // mine\n\
# }\n\
{\n \"b\": 2\n}\n";
crate::hurl::parse_hurl(text).remove(0)
}
#[test]
fn the_body_section_says_when_notes_no_longer_match() {
let st = Strings::for_language(&Language::English);
let mut entry = entry_with_stale_notes();
let out = draw_body_section(&mut entry);
assert!(
out.iter().any(|t| t.contains(st.gui_notes_stale_headline)),
"expected the stale-notes notice, painted: {out:?}"
);
assert!(
out.iter().any(|t| t.contains(st.notes_stale_adopt)),
"expected the adopt button, painted: {out:?}"
);
let mut plain = HurlEntry {
body_src: Some("{\"a\": 1}".into()),
..Default::default()
};
let out = draw_body_section(&mut plain);
assert!(!out.iter().any(|t| t.contains(st.gui_notes_stale_headline)));
}
#[test]
fn notes_that_cannot_be_adopted_say_so_on_the_button() {
let st = Strings::for_language(&Language::English);
let text = "POST http://h/a\n\
# [Body] 4\n\
# {\n\
# //extra\n\
# \"a\": 1 // mine\n\
#\n\
# }\n\
{\n \"b\": 2\n}\n";
let mut entry = crate::hurl::parse_hurl(text).remove(0);
assert!(!entry.can_adopt_body_notes());
let out = draw_body_section(&mut entry);
assert!(
out.iter().any(|t| t.contains(st.notes_stale_adopt_blocked)),
"the blocked reason should be on the button, painted: {out:?}"
);
}
fn draw_body_section(entry: &mut HurlEntry) -> Vec<String> {
let th = GuiTheme::from_spec(&crate::theme::default_preset());
let st = Strings::for_language(&Language::English);
let ctx = egui::Context::default();
th.apply(&ctx);
let mut browse = None;
let mut out = Vec::new();
for _ in 0..2 {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(800.0, 600.0),
)),
..Default::default()
};
let full = ctx.run_ui(input, |ui| {
draw_section(
EditorSection::Body,
ui,
&th,
&st,
entry,
&[],
&mut browse,
&mut None,
);
});
out = painted(&full.shapes);
}
out
}
#[test]
fn the_body_section_opens_on_the_form_when_the_request_posts_one() {
let mut entry = HurlEntry {
form_fields: vec![crate::hurl::FormField {
key: "grant_type".into(),
value: "client_credentials".into(),
enabled: true,
..Default::default()
}],
..Default::default()
};
let shown = draw_body_section(&mut entry);
assert!(
shown.iter().any(|t| t == "grant_type"),
"the form field is shown: {shown:?}"
);
let st = Strings::for_language(&Language::English);
assert!(
!shown.iter().any(|t| t == st.gui_raw_body_hint),
"the empty body box is not taking up the panel: {shown:?}"
);
}
#[test]
fn the_body_section_opens_on_the_body_when_there_are_no_form_fields() {
let mut entry = HurlEntry::default();
let shown = draw_body_section(&mut entry);
let st = Strings::for_language(&Language::English);
assert!(
shown.iter().any(|t| t == st.gui_raw_body_hint),
"the raw body editor is shown: {shown:?}"
);
}
#[test]
fn carrying_both_a_body_and_form_fields_is_reported_in_the_section() {
let mut entry = HurlEntry {
body_src: Some(" ".into()),
form_fields: vec![crate::hurl::FormField {
key: "grant_type".into(),
enabled: true,
..Default::default()
}],
..Default::default()
};
let shown = draw_body_section(&mut entry);
let st = Strings::for_language(&Language::English);
assert!(
shown.iter().any(|t| t == st.gui_body_conflict_headline),
"the conflict is named: {shown:?}"
);
assert!(
shown.iter().any(|t| t == st.gui_body_conflict_clear),
"and the fix is offered: {shown:?}"
);
}
#[test]
fn editable_highlighter_preserves_the_buffer_text_verbatim() {
let th = GuiTheme::from_spec(&Session::default().active_theme_spec());
let vars = HashMap::new();
let shadowed = HashSet::new();
let mut seen = SubstSeen::default();
for text in [
"GET https://x/{{ host }}/api\nAuthorization: {{ token }}",
"no placeholders here",
"trailing {{ unclosed",
"{{a}}{{b}} back to back",
] {
let job = highlight_code_editable(
text,
&vars,
&shadowed,
&th,
FontId::monospace(12.0),
&mut seen,
);
assert_eq!(job.text, text, "layouter must not alter the buffer text");
}
}
#[test]
fn editing_the_hurl_buffer_keeps_what_the_text_cannot_say() {
let strings = Strings::for_language(&Language::English);
let mut session = session_with_entry();
let mut code = super::super::app::CodeEdit::default();
{
let e = &mut session.collections[0].entries[0];
e.baseline = Some(e.to_hurl());
e.uid = 42;
e.user_added = true;
}
let saved = session.collections[0].entries[0].baseline.clone();
let mut edited = session.collections[0].entries[0].clone();
edited.headers.push(KvRow::toggled("X-Test", "hello", true));
let text = edited.to_hurl();
assert!(apply_code_edit(
&mut session,
&mut code,
&strings,
0,
0,
true,
&text
));
session.collections[0].entries[0].mark_edited();
assert!(
session.collections[0].entries[0].modified,
"a real change is marked"
);
let back = saved.clone().unwrap();
assert!(apply_code_edit(
&mut session,
&mut code,
&strings,
0,
0,
true,
&back
));
session.collections[0].entries[0].mark_edited();
let e = &session.collections[0].entries[0];
assert_eq!(
e.baseline, saved,
"the file's text has to survive a reparse"
);
assert_eq!(e.uid, 42, "and the entry's identity in the list");
assert!(e.user_added, "and the UI-only marker");
assert!(
!e.modified,
"back to what the file says, so there is nothing to save"
);
}
#[test]
fn editing_the_hurl_buffer_roundtrips_a_new_header_into_the_entry() {
let strings = Strings::for_language(&Language::English);
let mut session = session_with_entry();
let mut code = super::super::app::CodeEdit::default();
let mut edited = session.collections[0].entries[0].clone();
edited.headers.push(KvRow::toggled("X-Test", "hello", true));
let text = edited.to_hurl();
let changed = apply_code_edit(&mut session, &mut code, &strings, 0, 0, true, &text);
assert!(changed, "a valid edit should report a change");
assert!(code.error.is_none(), "a valid edit clears the error");
let hdrs = &session.collections[0].entries[0].headers;
assert!(
hdrs.iter().any(|r| r.key == "X-Test" && r.value == "hello"),
"expected the new header to be applied, got {hdrs:?}"
);
}
#[test]
fn invalid_hurl_keeps_the_entry_and_records_an_error() {
let strings = Strings::for_language(&Language::English);
let mut session = session_with_entry();
let before = session.collections[0].entries[0].clone();
let mut code = super::super::app::CodeEdit::default();
let changed = apply_code_edit(
&mut session,
&mut code,
&strings,
0,
0,
true,
"not a request",
);
assert!(!changed, "an unparseable edit must not report a change");
assert!(code.error.is_some(), "an unparseable edit records an error");
let entry = &session.collections[0].entries[0];
assert_eq!(entry.method, before.method);
assert_eq!(entry.url, before.url);
assert_eq!(entry.headers, before.headers);
}
#[test]
fn editing_the_json_buffer_roundtrips_the_method_into_the_entry() {
let strings = Strings::for_language(&Language::English);
let mut session = session_with_entry();
let mut code = super::super::app::CodeEdit::default();
let mut edited = session.collections[0].entries[0].clone();
edited.method = "POST".into();
let text = build_request_json(&edited);
let changed = apply_code_edit(&mut session, &mut code, &strings, 0, 0, false, &text);
assert!(changed, "a valid JSON edit should report a change");
assert!(code.error.is_none());
assert_eq!(session.collections[0].entries[0].method, "POST");
}
#[test]
fn invalid_json_keeps_the_entry_and_records_an_error() {
let strings = Strings::for_language(&Language::English);
let mut session = session_with_entry();
let before_method = session.collections[0].entries[0].method.clone();
let mut code = super::super::app::CodeEdit::default();
let changed = apply_code_edit(&mut session, &mut code, &strings, 0, 0, false, "{ not json");
assert!(!changed, "malformed JSON must not report a change");
assert!(code.error.is_some(), "malformed JSON records an error");
assert_eq!(session.collections[0].entries[0].method, before_method);
}
}
#[cfg(test)]
mod highlight_cache_tests {
use super::*;
use crate::gui::theme::GuiTheme;
use crate::request::{SubstInfo, SubstKind};
fn vars() -> HashMap<String, SubstInfo> {
[
(
"BASE",
SubstInfo {
shown: Some("https://x".into()),
kind: SubstKind::Literal,
},
),
(
"TOKEN",
SubstInfo {
shown: None,
kind: SubstKind::Pending,
},
),
(
"SECRET",
SubstInfo {
shown: Some("s".into()),
kind: SubstKind::Loaded,
},
),
]
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect()
}
#[test]
fn the_legend_scan_agrees_with_the_highlighter_it_replaced() {
let vars = vars();
let shadowed: HashSet<String> = ["SECRET".to_string()].into_iter().collect();
for text in [
"",
"GET {{ BASE }}/a",
"GET {{BASE}}/a\nAuth: {{ TOKEN }}\nX: {{ SECRET }}\n",
"{{ UNKNOWN }} and an unclosed {{ one",
"no placeholders at all",
] {
let mut from_highlighter = SubstSeen::default();
let _ = highlight_code_editable(
text,
&vars,
&shadowed,
&GuiTheme::from_spec(&crate::theme::default_preset()),
FontId::monospace(12.0),
&mut from_highlighter,
);
let scanned = substitution_statuses(text, &vars, &shadowed);
assert_eq!(
(
scanned.loaded,
scanned.literal,
scanned.pending,
scanned.failed,
scanned.shadowed
),
(
from_highlighter.loaded,
from_highlighter.literal,
from_highlighter.pending,
from_highlighter.failed,
from_highlighter.shadowed
),
"disagreed on {text:?}"
);
}
}
#[test]
fn the_editable_highlighter_shows_the_placeholder_not_the_value() {
let th = GuiTheme::from_spec(&crate::theme::default_preset());
let ctx = egui::Context::default();
let font = FontId::monospace(12.0);
let shadowed = HashSet::new();
let text = "GET {{ BASE }}/a";
let render = |shown: &str| {
let vars: HashMap<String, SubstInfo> = [(
"BASE".to_string(),
SubstInfo {
shown: Some(shown.to_string()),
kind: SubstKind::Literal,
},
)]
.into_iter()
.collect();
let mut out = String::new();
for _ in 0..2 {
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
out = cached_code_job(ui, text, &vars, &shadowed, &th, font.clone()).text;
});
}
out
};
assert_eq!(
render("https://staging"),
text,
"the buffer keeps its placeholders; only their colour comes from the value"
);
assert_eq!(render("https://staging"), render("https://prod"));
}
#[test]
fn the_cached_job_matches_a_freshly_built_one_and_follows_an_edit() {
let vars = vars();
let shadowed = HashSet::new();
let th = GuiTheme::from_spec(&crate::theme::default_preset());
let ctx = egui::Context::default();
let font = FontId::monospace(12.0);
let sections = |text: &str| {
let mut got = Vec::new();
for _ in 0..2 {
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let job = cached_code_job(ui, text, &vars, &shadowed, &th, font.clone());
got = job
.sections
.iter()
.map(|s| (s.byte_range.clone(), s.format.color))
.collect();
});
}
got
};
let fresh = |text: &str| {
let mut ignored = SubstSeen::default();
highlight_code_editable(text, &vars, &shadowed, &th, font.clone(), &mut ignored)
.sections
.iter()
.map(|s| (s.byte_range.clone(), s.format.color))
.collect::<Vec<_>>()
};
let one = "GET {{ BASE }}/a";
assert_eq!(sections(one), fresh(one), "same colouring, cached or not");
let two = "GET {{ TOKEN }}/a";
assert_eq!(
sections(two),
fresh(two),
"an edit re-colours rather than serving the previous buffer"
);
assert_ne!(
sections(one),
sections(two),
"and the two really do colour differently"
);
}
}
pub(super) fn apply_extract_parameter(
app: &mut GuiApp,
ci: usize,
entry: usize,
target: ExtractTarget,
range: Option<std::ops::Range<usize>>,
value: &str,
name: &str,
) {
let name = name.trim();
let Some(col) = app.session.collections.get_mut(ci) else {
return;
};
let Some(e) = col.entries.get_mut(entry) else {
return;
};
if crate::hurl::check_parameter_name(name, value, &e.variable_defaults()).is_some() {
return;
}
let already = e.declares_variable(name);
let placeholder = format!("{{{{{name}}}}}");
let replace = |field: &mut String| -> bool {
match &range {
Some(r) if field.get(r.clone()) == Some(value) => {
field.replace_range(r.clone(), &placeholder);
true
}
Some(_) => false,
None if field == value => {
*field = placeholder.clone();
true
}
None => false,
}
};
let applied = match target {
ExtractTarget::Url => replace(&mut e.url),
ExtractTarget::Body => match e.body_src.as_mut() {
Some(b) => replace(b),
None => false,
},
ExtractTarget::FormField(i) => match e.form_fields.get_mut(i) {
Some(f) => replace(&mut f.value),
None => false,
},
ExtractTarget::Kv(kind, i) => {
let rows = match kind {
KvSectionKind::Query => &mut e.queries,
KvSectionKind::Header => &mut e.headers,
KvSectionKind::Cookie => &mut e.cookies,
KvSectionKind::Option => &mut e.options,
};
match rows.get_mut(i) {
Some(r) => replace(&mut r.value),
None => false,
}
}
};
if !applied {
return;
}
if !already {
e.options.push(KvRow::toggled(
"variable".to_string(),
format!("{name}={value}"),
true,
));
}
e.mark_edited();
col.invalidate_request_json();
}
pub(super) fn apply_picked_form_file(
app: &mut GuiApp,
ci: usize,
entry: usize,
field: usize,
picked: Option<std::path::PathBuf>,
) {
let Some(path) = picked else {
return; };
let Some(col) = app.session.collections.get_mut(ci) else {
return;
};
let Some(e) = col.entries.get_mut(entry) else {
return;
};
let Some(f) = e.form_fields.get_mut(field) else {
return;
};
f.value = path.to_string_lossy().into_owned();
e.mark_edited();
col.invalidate_request_json();
}
#[cfg(test)]
mod pick_tests {
use super::*;
fn app_with_form_field(value: &str) -> GuiApp {
let mut session = crate::session::Session::default();
session.collections.clear();
let entry = crate::hurl::HurlEntry {
title: "A".into(),
url: "http://127.0.0.1:1/".into(),
form_fields: vec![crate::hurl::FormField {
key: "f".into(),
kind: crate::hurl::FormFieldKind::File,
value: value.into(),
..Default::default()
}],
..Default::default()
};
session.collections.push(crate::collection::Collection::new(
"api".into(),
vec![entry],
));
GuiApp::for_test(session)
}
#[test]
fn a_picked_form_file_lands_in_its_field_and_marks_it_modified() {
let mut app = app_with_form_field("");
apply_picked_form_file(&mut app, 0, 0, 0, Some("/tmp/face.jpg".into()));
assert_eq!(
app.session.collections[0].entries[0].form_fields[0].value,
"/tmp/face.jpg"
);
assert!(app.session.collections[0].entries[0].modified);
}
#[test]
fn cancelling_leaves_the_field_untouched() {
let mut app = app_with_form_field("kept.jpg");
apply_picked_form_file(&mut app, 0, 0, 0, None);
assert_eq!(
app.session.collections[0].entries[0].form_fields[0].value,
"kept.jpg"
);
assert!(!app.session.collections[0].entries[0].modified);
}
#[test]
fn a_path_for_a_row_that_no_longer_exists_is_dropped() {
let mut app = app_with_form_field("");
apply_picked_form_file(&mut app, 0, 0, 7, Some("/tmp/x.jpg".into()));
apply_picked_form_file(&mut app, 0, 9, 0, Some("/tmp/x.jpg".into()));
apply_picked_form_file(&mut app, 5, 0, 0, Some("/tmp/x.jpg".into()));
assert_eq!(
app.session.collections[0].entries[0].form_fields[0].value,
""
);
assert!(!app.session.collections[0].entries[0].modified);
}
}
#[cfg(test)]
mod extract_tests {
use super::*;
use crate::hurl::{FormField, FormFieldKind, HurlEntry, KvRow};
fn app_with(entry: HurlEntry) -> GuiApp {
let mut session = crate::session::Session::default();
session.collections.clear();
session.collections.push(crate::collection::Collection::new(
"api".into(),
vec![entry],
));
GuiApp::for_test(session)
}
fn form_entry(value: &str) -> HurlEntry {
HurlEntry {
title: "upload".into(),
url: "http://h/upload".into(),
form_fields: vec![FormField {
key: "document".into(),
kind: FormFieldKind::File,
value: value.into(),
..Default::default()
}],
..Default::default()
}
}
#[test]
fn extracting_a_form_value_replaces_it_and_declares_the_parameter() {
let mut app = app_with(form_entry("./samples/example.pdf"));
apply_extract_parameter(
&mut app,
0,
0,
ExtractTarget::FormField(0),
None,
"./samples/example.pdf",
"FILE",
);
let e = &app.session.collections[0].entries[0];
assert_eq!(e.form_fields[0].value, "{{FILE}}");
assert_eq!(
e.variable_defaults(),
vec![("FILE".to_string(), "./samples/example.pdf".to_string())]
);
assert!(e.modified, "the request now differs from the file on disk");
}
#[test]
fn extracting_a_url_selection_leaves_the_rest_of_the_url_alone() {
let mut app = app_with(HurlEntry {
url: "http://h/orders/12345/items".into(),
..form_entry("x")
});
let range = "http://h/orders/".len().."http://h/orders/12345".len();
apply_extract_parameter(
&mut app,
0,
0,
ExtractTarget::Url,
Some(range),
"12345",
"ORDER",
);
let e = &app.session.collections[0].entries[0];
assert_eq!(e.url, "http://h/orders/{{ORDER}}/items");
assert_eq!(
e.variable_defaults(),
vec![("ORDER".to_string(), "12345".to_string())]
);
}
#[test]
fn extracting_the_same_value_twice_reuses_the_one_declaration() {
let mut entry = form_entry("./samples/example.pdf");
entry
.headers
.push(KvRow::toggled("X-Source", "./samples/example.pdf", true));
let mut app = app_with(entry);
for target in [
ExtractTarget::FormField(0),
ExtractTarget::Kv(KvSectionKind::Header, 0),
] {
apply_extract_parameter(
&mut app,
0,
0,
target,
None,
"./samples/example.pdf",
"FILE",
);
}
let e = &app.session.collections[0].entries[0];
assert_eq!(e.form_fields[0].value, "{{FILE}}");
assert_eq!(e.headers[0].value, "{{FILE}}");
assert_eq!(e.variable_defaults().len(), 1, "one declaration, shared");
}
#[test]
fn a_name_that_already_means_something_else_is_refused() {
let mut entry = form_entry("./samples/other.pdf");
entry.options.push(KvRow::toggled(
"variable",
"FILE=./samples/example.pdf",
true,
));
let mut app = app_with(entry);
apply_extract_parameter(
&mut app,
0,
0,
ExtractTarget::FormField(0),
None,
"./samples/other.pdf",
"FILE",
);
let e = &app.session.collections[0].entries[0];
assert_eq!(
e.form_fields[0].value, "./samples/other.pdf",
"the field is untouched"
);
assert_eq!(e.variable_defaults().len(), 1, "and nothing was declared");
}
#[test]
fn a_field_that_changed_under_the_dialog_is_left_alone() {
let mut app = app_with(form_entry("./samples/changed.pdf"));
apply_extract_parameter(
&mut app,
0,
0,
ExtractTarget::FormField(0),
None,
"./samples/example.pdf",
"FILE",
);
let e = &app.session.collections[0].entries[0];
assert_eq!(e.form_fields[0].value, "./samples/changed.pdf");
assert!(e.variable_defaults().is_empty(), "and nothing was declared");
assert!(!e.modified);
}
#[test]
fn a_row_that_no_longer_exists_is_dropped() {
let mut app = app_with(form_entry("./samples/example.pdf"));
apply_extract_parameter(
&mut app,
0,
0,
ExtractTarget::FormField(7),
None,
"./samples/example.pdf",
"FILE",
);
let e = &app.session.collections[0].entries[0];
assert_eq!(e.form_fields[0].value, "./samples/example.pdf");
assert!(e.variable_defaults().is_empty());
}
#[test]
fn a_char_range_over_multibyte_text_maps_to_the_right_bytes() {
let text = "héllo wörld";
let chars: Vec<char> = text.chars().collect();
let start = 6; let end = 11;
let r = char_range_to_bytes(text, start..end).expect("a non-empty range");
assert_eq!(&text[r], chars[start..end].iter().collect::<String>());
assert_eq!(
char_range_to_bytes(text, 3..3),
None,
"an empty range is no selection"
);
}
}
#[cfg(test)]
mod unreadable_tests {
use super::*;
use crate::gui::app::GuiApp;
use crate::i18n::Language;
pub(super) fn painted(shapes: &[egui::epaint::ClippedShape]) -> Vec<String> {
fn walk(shape: &egui::epaint::Shape, out: &mut Vec<String>) {
match shape {
egui::epaint::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut out = Vec::new();
for c in shapes {
walk(&c.shape, &mut out);
}
out
}
fn app_with_unreadable() -> GuiApp {
let mut session = crate::session::Session::default();
session.collections.clear();
let entries = crate::hurl::parse_hurl("POST http://h/a\n[Captures]\nx: jsonpath \"$.a\"\n");
assert!(entries[0].is_unreadable());
session
.collections
.push(crate::collection::Collection::new("api".into(), entries));
GuiApp::for_test(session)
}
#[test]
fn the_editor_shows_the_text_and_says_it_could_not_be_read() {
let mut app = app_with_unreadable();
let st = Strings::for_language(&Language::English);
let th = GuiTheme::from_spec(&crate::theme::default_preset());
let ctx = egui::Context::default();
th.apply(&ctx);
let mut out = Vec::new();
for _ in 0..2 {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(900.0, 700.0),
)),
..Default::default()
};
let full = ctx.run_ui(input, |u| super::ui(&mut app, u));
out = painted(&full.shapes);
}
assert!(
out.iter().any(|t| t.contains(st.cannot_edit_unreadable)),
"expected the explanation, painted: {out:?}"
);
assert!(
out.iter().any(|t| t.contains("[Captures]")),
"expected the request's own text, painted: {out:?}"
);
}
}
#[cfg(test)]
mod computed_tests {
use super::unreadable_tests::painted;
use super::*;
use crate::i18n::Language;
#[test]
fn the_editor_has_a_section_for_computed_values() {
let mut session = crate::session::Session::default();
let mut entry = HurlEntry::default();
entry.method = "GET".into();
entry.url = "https://h/a".into();
entry.title = "Demo".into();
entry.generators = vec![("nonce".into(), "random_hex(16)".into())];
session.collections[0].entries = vec![entry];
session.collections[0].selected_entry = 0;
let mut app = GuiApp::for_test(session);
app.editor_section = EditorSection::Computed;
let st = Strings::for_language(&Language::English);
let th = GuiTheme::from_spec(&crate::theme::default_preset());
let ctx = egui::Context::default();
th.apply(&ctx);
let mut out = Vec::new();
for _ in 0..2 {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(900.0, 700.0),
)),
..Default::default()
};
let full = ctx.run_ui(input, |u| super::ui(&mut app, u));
out = painted(&full.shapes);
}
assert!(
out.iter().any(|t| t.contains(st.gui_sec_generated)),
"expected the section tab, painted: {out:?}"
);
assert!(
out.iter().any(|t| t.contains("random_hex(16)")),
"expected the row itself, painted: {out:?}"
);
}
#[test]
fn the_computed_section_says_when_a_row_cannot_run() {
let mut session = crate::session::Session::default();
let mut entry = HurlEntry::default();
entry.method = "GET".into();
entry.url = "https://h/a".into();
entry.title = "Demo".into();
entry.generators = vec![("sig".into(), "hmac_sha526(k, m)".into())];
session.collections[0].entries = vec![entry];
session.collections[0].selected_entry = 0;
let mut app = GuiApp::for_test(session);
app.editor_section = EditorSection::Computed;
let st = Strings::for_language(&Language::English);
let th = GuiTheme::from_spec(&crate::theme::default_preset());
let ctx = egui::Context::default();
th.apply(&ctx);
let mut out = Vec::new();
for _ in 0..2 {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(900.0, 700.0),
)),
..Default::default()
};
let full = ctx.run_ui(input, |u| super::ui(&mut app, u));
out = painted(&full.shapes);
}
assert!(
out.iter().any(|t| t.contains(st.gui_generated_faults)),
"expected the heading, painted: {out:?}"
);
assert!(
out.iter().any(|t| t.contains("hmac_sha526")),
"and the offending name, painted: {out:?}"
);
}
#[test]
fn a_name_that_is_not_a_variable_is_painted_as_an_error() {
fn coloured(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, egui::Color32)> {
fn walk(shape: &egui::epaint::Shape, out: &mut Vec<(String, egui::Color32)>) {
match shape {
egui::epaint::Shape::Text(t) => {
let colour = t
.galley
.job
.sections
.first()
.map(|s| s.format.color)
.unwrap_or(egui::Color32::PLACEHOLDER);
out.push((t.galley.text().to_string(), colour));
}
egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut out = Vec::new();
for c in shapes {
walk(&c.shape, &mut out);
}
out
}
let mut session = crate::session::Session::default();
let mut entry = HurlEntry::default();
entry.method = "GET".into();
entry.url = "https://h/a".into();
entry.title = "Demo".into();
entry.generators = vec![
("good".into(), "uuid".into()),
("no spaces".into(), "uuid".into()),
];
session.collections[0].entries = vec![entry];
session.collections[0].selected_entry = 0;
let mut app = GuiApp::for_test(session);
app.editor_section = EditorSection::Computed;
let th = GuiTheme::from_spec(&crate::theme::default_preset());
let ctx = egui::Context::default();
th.apply(&ctx);
let mut out = Vec::new();
for _ in 0..2 {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(900.0, 700.0),
)),
..Default::default()
};
let full = ctx.run_ui(input, |u| super::ui(&mut app, u));
out = coloured(&full.shapes);
}
let err = th.err;
let good = out
.iter()
.find(|(t, _)| t == "good")
.expect("valid name painted");
let bad = out
.iter()
.find(|(t, _)| t == "no spaces")
.expect("invalid name painted");
assert_ne!(good.1, err, "a valid name is not flagged");
assert_eq!(bad.1, err, "an invalid name is painted in the error colour");
}
}
#[cfg(test)]
mod computed_cell_identity_tests {
use super::*;
fn app_with_rows(rows: &[(&str, &str)]) -> GuiApp {
let mut session = crate::session::Session::default();
let mut entry = HurlEntry::default();
entry.method = "GET".into();
entry.url = "https://h/a".into();
entry.title = "Demo".into();
entry.generators = rows
.iter()
.map(|(n, e)| (n.to_string(), e.to_string()))
.collect();
session.collections[0].entries = vec![entry];
session.collections[0].selected_entry = 0;
let mut app = GuiApp::for_test(session);
app.editor_section = EditorSection::Computed;
app
}
fn raw(events: Vec<egui::Event>) -> egui::RawInput {
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(900.0, 700.0),
)),
events,
..Default::default()
}
}
fn placed(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, egui::Rect)> {
fn walk(shape: &egui::epaint::Shape, out: &mut Vec<(String, egui::Rect)>) {
match shape {
egui::epaint::Shape::Text(t) => {
out.push((t.galley.text().to_string(), t.visual_bounding_rect()))
}
egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut out = Vec::new();
for c in shapes {
walk(&c.shape, &mut out);
}
out
}
fn click(pos: egui::Pos2) -> Vec<egui::Event> {
vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: Default::default(),
},
egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: Default::default(),
},
]
}
fn focus_cell(app: &mut GuiApp, ctx: &egui::Context, needle: &str) -> egui::Id {
let mut at = None;
for _ in 0..3 {
let out = ctx.run_ui(raw(vec![]), |u| super::ui(app, u));
at = placed(&out.shapes)
.into_iter()
.find(|(t, _)| t == needle)
.map(|(_, r)| r.center());
}
let at = at.unwrap_or_else(|| panic!("{needle} was never painted"));
let _ = ctx.run_ui(raw(click(at)), |u| super::ui(app, u));
let _ = ctx.run_ui(raw(vec![]), |u| super::ui(app, u));
ctx.memory(|m| m.focused())
.unwrap_or_else(|| panic!("clicking {needle} focused nothing"))
}
#[test]
fn a_removed_rows_undo_history_is_not_inherited_by_the_row_below() {
let mut app = app_with_rows(&[("nonce", "uuid"), ("stamp", "timestamp")]);
let th = GuiTheme::from_spec(&crate::theme::default_preset());
let ctx = egui::Context::default();
th.apply(&ctx);
let first_id = focus_cell(&mut app, &ctx, "uuid");
let second_id = focus_cell(&mut app, &ctx, "timestamp");
assert_ne!(first_id, second_id, "two rows, two fields");
app.session.collections[0].entries[0].generators.remove(0);
for _ in 0..3 {
let _ = ctx.run_ui(raw(vec![]), |u| super::ui(&mut app, u));
}
let moved_up_id = focus_cell(&mut app, &ctx, "timestamp");
assert_eq!(
moved_up_id, second_id,
"the surviving row kept its own field identity (id {second_id:?} vs {moved_up_id:?}); \
if this fails it has inherited the deleted row's ({first_id:?})"
);
}
#[test]
fn the_surviving_row_does_not_inherit_the_deleted_rows_caret() {
let mut app = app_with_rows(&[("nonce", "uuid"), ("stamp", "timestamp")]);
let th = GuiTheme::from_spec(&crate::theme::default_preset());
let ctx = egui::Context::default();
th.apply(&ctx);
let first_id = focus_cell(&mut app, &ctx, "uuid");
let mut st = egui::TextEdit::load_state(&ctx, first_id).expect("state for a focused field");
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(
egui::text::CCursor::new(4),
)));
egui::TextEdit::store_state(&ctx, first_id, st);
app.session.collections[0].entries[0].generators.remove(0);
for _ in 0..3 {
let _ = ctx.run_ui(raw(vec![]), |u| super::ui(&mut app, u));
}
let now_id = focus_cell(&mut app, &ctx, "timestamp");
assert_ne!(
now_id, first_id,
"the row that moved up is being drawn with the deleted row's field state"
);
}
}
#[cfg(test)]
mod computed_cell_undo_tests {
use super::*;
fn app_with_rows(rows: &[(&str, &str)]) -> GuiApp {
let mut session = crate::session::Session::default();
let mut entry = HurlEntry::default();
entry.method = "GET".into();
entry.url = "https://h/a".into();
entry.title = "Demo".into();
entry.generators = rows
.iter()
.map(|(n, e)| (n.to_string(), e.to_string()))
.collect();
session.collections[0].entries = vec![entry];
session.collections[0].selected_entry = 0;
let mut app = GuiApp::for_test(session);
app.editor_section = EditorSection::Computed;
app
}
fn placed(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, egui::Rect)> {
fn walk(shape: &egui::epaint::Shape, out: &mut Vec<(String, egui::Rect)>) {
match shape {
egui::epaint::Shape::Text(t) => {
out.push((t.galley.text().to_string(), t.visual_bounding_rect()))
}
egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut out = Vec::new();
for c in shapes {
walk(&c.shape, &mut out);
}
out
}
pub(super) struct Harness {
ctx: egui::Context,
t: f64,
}
impl Harness {
pub(super) fn new() -> Self {
let ctx = egui::Context::default();
GuiTheme::from_spec(&crate::theme::default_preset()).apply(&ctx);
Self { ctx, t: 0.0 }
}
pub(super) fn frame(
&mut self,
app: &mut GuiApp,
events: Vec<egui::Event>,
dt: f64,
) -> Vec<(String, egui::Rect)> {
self.t += dt;
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(900.0, 700.0),
)),
time: Some(self.t),
events,
..Default::default()
};
let out = self.ctx.run_ui(input, |u| super::ui(app, u));
placed(&out.shapes)
}
fn settle(&mut self, app: &mut GuiApp) {
for _ in 0..6 {
self.frame(app, vec![], 0.4);
}
}
pub(super) fn hover_text(&mut self, app: &mut GuiApp, needle: &str) {
let at = self
.frame(app, vec![], 0.05)
.into_iter()
.find(|(t, _)| t == needle)
.map(|(_, r)| r.center())
.unwrap_or_else(|| panic!("{needle} was never painted"));
self.frame(app, vec![egui::Event::PointerMoved(at)], 0.05);
self.frame(app, vec![], 0.05);
}
pub(super) fn click_text(&mut self, app: &mut GuiApp, needle: &str) {
let mut at = None;
for _ in 0..3 {
at = self
.frame(app, vec![], 0.05)
.into_iter()
.find(|(t, _)| t == needle)
.map(|(_, r)| r.center());
}
let at = at.unwrap_or_else(|| panic!("{needle} was never painted"));
let ev = vec![
egui::Event::PointerMoved(at),
egui::Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: Default::default(),
},
egui::Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: Default::default(),
},
];
self.frame(app, ev, 0.05);
self.frame(app, vec![], 0.05);
}
}
fn rows(app: &GuiApp) -> Vec<(String, String)> {
app.session.collections[0].entries[0].generators.clone()
}
#[test]
fn ctrl_z_after_deleting_a_row_does_not_resurrect_it_into_the_row_below() {
let mut app = app_with_rows(&[("nonce", "uuid"), ("stamp", "timestamp")]);
let mut h = Harness::new();
h.click_text(&mut app, "uuid");
h.frame(&mut app, vec![egui::Event::Text("X".into())], 0.05);
h.settle(&mut app);
let edited = rows(&app)[0].1.clone();
assert_ne!(edited, "uuid", "the first row was edited");
app.session.collections[0].entries[0].generators.remove(0);
h.frame(&mut app, vec![], 0.05);
assert_eq!(
rows(&app),
vec![("stamp".to_string(), "timestamp".to_string())]
);
h.click_text(&mut app, "timestamp");
let ctrl = egui::Modifiers {
ctrl: true,
command: true,
..Default::default()
};
h.frame(
&mut app,
vec![egui::Event::Key {
key: egui::Key::Z,
physical_key: None,
pressed: true,
repeat: false,
modifiers: ctrl,
}],
0.05,
);
h.settle(&mut app);
assert_eq!(
rows(&app)[0].1,
"timestamp",
"Ctrl+Z in the surviving row rewrote it with the deleted row's expression"
);
}
#[test]
fn switching_requests_does_not_carry_a_cells_undo_history_across() {
let mut session = crate::session::Session::default();
let mk = |title: &str, row: (&str, &str)| {
let mut e = HurlEntry::default();
e.method = "GET".into();
e.url = "https://h/a".into();
e.title = title.into();
e.generators = vec![(row.0.to_string(), row.1.to_string())];
e
};
session.collections[0].entries = vec![
mk("First", ("nonce", "uuid")),
mk("Second", ("stamp", "timestamp")),
];
session.collections[0].selected_entry = 0;
let mut app = GuiApp::for_test(session);
app.editor_section = EditorSection::Computed;
let mut h = Harness::new();
h.click_text(&mut app, "uuid");
h.frame(&mut app, vec![egui::Event::Text("X".into())], 0.05);
h.settle(&mut app);
assert_ne!(
app.session.collections[0].entries[0].generators[0].1, "uuid",
"the first request's row was edited"
);
app.session.collections[0].selected_entry = 1;
h.frame(&mut app, vec![], 0.05);
h.click_text(&mut app, "timestamp");
let ctrl = egui::Modifiers {
ctrl: true,
command: true,
..Default::default()
};
h.frame(
&mut app,
vec![egui::Event::Key {
key: egui::Key::Z,
physical_key: None,
pressed: true,
repeat: false,
modifiers: ctrl,
}],
0.05,
);
h.frame(&mut app, vec![], 0.05);
assert_eq!(
app.session.collections[0].entries[1].generators[0].1, "timestamp",
"Ctrl+Z in the second request rewrote its row with the first request's expression"
);
}
#[test]
fn switching_requests_does_not_carry_a_header_cells_undo_history_across() {
let mut session = crate::session::Session::default();
let mk = |title: &str, v: &str| {
let mut e = HurlEntry::default();
e.method = "GET".into();
e.url = "https://h/a".into();
e.title = title.into();
e.headers = vec![crate::hurl::KvRow {
key: "X-Thing".into(),
value: v.into(),
enabled: true,
..Default::default()
}];
e
};
session.collections[0].entries = vec![mk("First", "alpha"), mk("Second", "bravo")];
session.collections[0].selected_entry = 0;
let mut app = GuiApp::for_test(session);
app.editor_section = EditorSection::Headers;
let mut h = Harness::new();
h.click_text(&mut app, "alpha");
h.frame(&mut app, vec![egui::Event::Text("X".into())], 0.05);
h.settle(&mut app);
assert_ne!(
app.session.collections[0].entries[0].headers[0].value,
"alpha"
);
app.session.collections[0].selected_entry = 1;
h.frame(&mut app, vec![], 0.05);
h.click_text(&mut app, "bravo");
let ctrl = egui::Modifiers {
ctrl: true,
command: true,
..Default::default()
};
h.frame(
&mut app,
vec![egui::Event::Key {
key: egui::Key::Z,
physical_key: None,
pressed: true,
repeat: false,
modifiers: ctrl,
}],
0.05,
);
h.frame(&mut app, vec![], 0.05);
assert_eq!(
app.session.collections[0].entries[1].headers[0].value, "bravo",
"Ctrl+Z in the second request rewrote its header with the first request's value"
);
}
}
#[cfg(test)]
mod computed_suggestion_tests {
use super::*;
fn app_with_row(name: &str, expr: &str) -> GuiApp {
app_with_rows(&[(name, expr)])
}
fn app_with_rows(rows: &[(&str, &str)]) -> GuiApp {
let mut session = crate::session::Session::default();
let mut entry = HurlEntry::default();
entry.method = "GET".into();
entry.url = "https://h/a".into();
entry.title = "Demo".into();
entry.generators = rows
.iter()
.map(|(n, e)| (n.to_string(), e.to_string()))
.collect();
session.collections[0].entries = vec![entry];
session.collections[0].selected_entry = 0;
let mut app = GuiApp::for_test(session);
app.editor_section = EditorSection::Computed;
app
}
fn expr(app: &GuiApp) -> String {
app.session.collections[0].entries[0].generators[0]
.1
.clone()
}
fn key(k: egui::Key) -> Vec<egui::Event> {
vec![
egui::Event::Key {
key: k,
physical_key: None,
pressed: true,
repeat: false,
modifiers: Default::default(),
},
egui::Event::Key {
key: k,
physical_key: None,
pressed: false,
repeat: false,
modifiers: Default::default(),
},
]
}
use super::computed_cell_undo_tests::Harness;
#[test]
fn a_function_with_an_optional_argument_still_wraps_what_is_there() {
let mut app = app_with_row("id", "uuid");
let mut h = Harness::new();
h.click_text(&mut app, "uuid");
h.frame(&mut app, key(egui::Key::Home), 0.05);
h.frame(&mut app, vec![], 0.05);
h.frame(&mut app, key(egui::Key::Enter), 0.05);
h.frame(&mut app, vec![], 0.05);
assert_eq!(
expr(&app),
"timestamp(uuid)",
"the word the caret was in front of should have been wrapped, not replaced"
);
}
#[test]
fn a_function_that_takes_nothing_replaces_the_word() {
let mut app = app_with_row("id", "uuid");
let mut h = Harness::new();
h.click_text(&mut app, "uuid");
h.frame(&mut app, key(egui::Key::Home), 0.05);
h.frame(&mut app, key(egui::Key::ArrowDown), 0.05);
h.frame(&mut app, key(egui::Key::Enter), 0.05);
h.frame(&mut app, vec![], 0.05);
assert_eq!(expr(&app), "timestamp_ms");
}
#[test]
fn pointing_at_a_row_describes_that_row() {
let mut app = app_with_row("digest", "sha");
let mut h = Harness::new();
h.click_text(&mut app, "sha");
h.frame(&mut app, vec![], 0.05);
h.hover_text(&mut app, "sha512(text)");
let texts: Vec<String> = h
.frame(&mut app, vec![], 0.05)
.into_iter()
.map(|(t, _)| t)
.collect();
let s = crate::i18n::Strings::for_language(&crate::i18n::Language::English);
assert!(
texts.iter().any(|t| t == s.gen_description("sha512")),
"the note still describes the row the keyboard was on: {texts:?}"
);
h.frame(&mut app, key(egui::Key::Enter), 0.05);
h.frame(&mut app, vec![], 0.05);
assert!(expr(&app).starts_with("sha512("), "{:?}", expr(&app));
}
#[test]
fn the_list_shows_what_the_highlighted_row_would_produce() {
let mut app = app_with_row("id", "uuid");
let mut h = Harness::new();
h.click_text(&mut app, "uuid");
h.frame(&mut app, key(egui::Key::Home), 0.05);
h.frame(&mut app, vec![], 0.05);
let painted = h.frame(&mut app, vec![], 0.05);
let texts: Vec<&String> = painted.iter().map(|(t, _)| t).collect();
assert!(
texts.iter().any(|t| t.contains("timestamp(uuid)")),
"no preview of the highlighted row under the list: {texts:?}"
);
}
#[test]
fn typing_a_prefix_offers_the_functions_that_match() {
let mut app = app_with_row("digest", "sha");
let mut h = Harness::new();
h.click_text(&mut app, "sha");
let painted = h.frame(&mut app, vec![], 0.05);
let texts: Vec<&String> = painted.iter().map(|(t, _)| t).collect();
assert!(
texts.iter().any(|t| t.contains("sha256(")),
"no suggestion list under the field: {texts:?}"
);
assert!(
!texts.iter().any(|t| t.contains("uuid")),
"the list should be filtered by what was typed: {texts:?}"
);
}
#[test]
fn enter_accepts_the_highlighted_suggestion() {
let mut app = app_with_row("digest", "sha");
let mut h = Harness::new();
h.click_text(&mut app, "sha");
h.frame(&mut app, key(egui::Key::Enter), 0.05);
h.frame(&mut app, vec![], 0.05);
assert!(
expr(&app).starts_with("sha1("),
"Enter should have written the first match in, but the field says {:?}",
expr(&app)
);
}
#[test]
fn down_then_enter_takes_the_next_suggestion() {
let mut app = app_with_row("digest", "sha");
let mut h = Harness::new();
h.click_text(&mut app, "sha");
h.frame(&mut app, key(egui::Key::ArrowDown), 0.05);
h.frame(&mut app, key(egui::Key::Enter), 0.05);
h.frame(&mut app, vec![], 0.05);
assert_ne!(
expr(&app),
"sha",
"the second suggestion was never accepted"
);
assert!(
!expr(&app).starts_with("sha1("),
"Down should have moved past the first match, got {:?}",
expr(&app)
);
}
#[test]
fn escape_dismisses_the_list_until_the_word_changes() {
let mut app = app_with_row("digest", "sha");
let mut h = Harness::new();
h.click_text(&mut app, "sha");
h.frame(&mut app, key(egui::Key::End), 0.05);
let before = h.frame(&mut app, vec![], 0.05);
assert!(
before.iter().any(|(t, _)| t.contains("sha256(")),
"the list should have been up before Esc"
);
h.frame(&mut app, key(egui::Key::Escape), 0.05);
let after = h.frame(&mut app, vec![], 0.05);
assert!(
!after.iter().any(|(t, _)| t.contains("sha256(")),
"Esc should have closed the list: {:?}",
after.iter().map(|(t, _)| t).collect::<Vec<_>>()
);
let back = h.frame(&mut app, vec![egui::Event::Text("2".into())], 0.05);
let back = if back.iter().any(|(t, _)| t.contains("sha256(")) {
back
} else {
h.frame(&mut app, vec![], 0.05)
};
assert!(
back.iter().any(|(t, _)| t.contains("sha256(")),
"typing another character should offer the list again: {:?}",
back.iter().map(|(t, _)| t).collect::<Vec<_>>()
);
}
#[test]
fn an_empty_expression_offers_everything() {
let mut app = app_with_row("nonce", "");
let mut h = Harness::new();
let hint = app.strings.gui_generated_expr_hint;
h.click_text(&mut app, hint);
let painted = h.frame(&mut app, vec![], 0.05);
let texts: Vec<&String> = painted.iter().map(|(t, _)| t).collect();
let calls = texts
.iter()
.filter(|t| t.contains('(') && t.ends_with(')'))
.count();
assert!(
calls >= 5,
"an empty cell should offer the list to browse: {texts:?}"
);
}
#[test]
fn ctrl_space_asks_for_the_list_again() {
let mut app = app_with_row("digest", "sha256");
let mut h = Harness::new();
h.click_text(&mut app, "sha256");
h.frame(&mut app, key(egui::Key::Escape), 0.05);
let quiet = h.frame(&mut app, vec![], 0.05);
assert!(
!quiet.iter().any(|(t, _)| t.contains("sha256(text)")),
"Escape should have put the list away"
);
let ctrl = egui::Modifiers {
ctrl: true,
command: true,
..Default::default()
};
h.frame(
&mut app,
vec![egui::Event::Key {
key: egui::Key::Space,
physical_key: None,
pressed: true,
repeat: false,
modifiers: ctrl,
}],
0.05,
);
let painted = h.frame(&mut app, vec![], 0.05);
assert!(
painted.iter().any(|(t, _)| t.contains("sha256(text)")),
"Ctrl+Space should have opened the list: {:?}",
painted.iter().map(|(t, _)| t).collect::<Vec<_>>()
);
}
#[test]
fn accepting_a_call_selects_its_first_argument() {
let mut app = app_with_row("sig", "hmac_sha256");
let mut h = Harness::new();
h.click_text(&mut app, "hmac_sha256");
h.frame(&mut app, key(egui::Key::End), 0.05);
h.frame(&mut app, key(egui::Key::Enter), 0.05);
h.frame(&mut app, vec![], 0.05);
assert_eq!(expr(&app), "hmac_sha256(key, message)");
h.frame(&mut app, vec![egui::Event::Text("API_SECRET".into())], 0.05);
h.frame(&mut app, vec![], 0.05);
assert_eq!(
expr(&app),
"hmac_sha256(API_SECRET, message)",
"typing should have replaced the selected argument name"
);
}
#[test]
fn the_list_offers_the_variables_the_expression_can_read() {
let mut app = app_with_rows(&[
("scope_above", "uuid"),
("target", ""),
("scope_below", "uuid"),
]);
let mut h = Harness::new();
let hint = app.strings.gui_generated_expr_hint;
h.click_text(&mut app, hint);
h.frame(&mut app, vec![egui::Event::Text("scope".into())], 0.05);
let painted = h.frame(&mut app, vec![], 0.05);
let count = |name: &str| painted.iter().filter(|(t, _)| t.as_str() == name).count();
assert_eq!(
count("scope_above"),
2,
"the row above is a variable this one can read: {:?}",
painted.iter().map(|(t, _)| t).collect::<Vec<_>>()
);
assert_eq!(
count("scope_below"),
1,
"a row below cannot be read, so offering it offers a mistake: {:?}",
painted.iter().map(|(t, _)| t).collect::<Vec<_>>()
);
}
#[test]
fn two_rows_with_the_same_expression_do_not_share_a_cell_id() {
let mut app = app_with_rows(&[("first", "uuid"), ("second", "uuid")]);
let mut h = Harness::new();
let painted = h.frame(&mut app, vec![], 0.05);
let clash: Vec<&String> = painted
.iter()
.map(|(t, _)| t)
.filter(|t| t.contains("widget ID"))
.collect();
assert!(clash.is_empty(), "the two rows collided: {clash:?}");
}
#[test]
fn typing_in_front_of_an_expression_offers_a_call_to_wrap_it() {
let mut app = app_with_row("stamp", "uuid");
let mut h = Harness::new();
h.click_text(&mut app, "uuid");
h.frame(&mut app, key(egui::Key::Home), 0.05);
h.frame(&mut app, vec![egui::Event::Text("base".into())], 0.05);
let painted = h.frame(&mut app, vec![], 0.05);
assert!(
painted.iter().any(|(t, _)| t.contains("base64(")),
"the typed prefix should filter the list: {:?}",
painted.iter().map(|(t, _)| t).collect::<Vec<_>>()
);
h.frame(&mut app, key(egui::Key::Enter), 0.05);
h.frame(&mut app, vec![], 0.05);
assert_eq!(
expr(&app),
"base64(uuid)",
"the expression the caret was in front of is the call's argument"
);
}
#[test]
fn wrapping_takes_the_whole_call_the_caret_is_in_front_of() {
let mut app = app_with_row("stamp", "sha256(body)");
let mut h = Harness::new();
h.click_text(&mut app, "sha256(body)");
h.frame(&mut app, key(egui::Key::Home), 0.05);
h.frame(&mut app, vec![egui::Event::Text("base64".into())], 0.05);
h.frame(&mut app, key(egui::Key::Enter), 0.05);
h.frame(&mut app, vec![], 0.05);
assert_eq!(expr(&app), "base64(sha256(body))");
}
#[test]
fn changing_an_expression_back_clears_the_edited_marker() {
let mut app = app_with_row("nonce", "uuid");
app.session.collections[0].reset_structure_baseline();
assert!(
!app.session.collections[0].entries[0].modified,
"a request straight off disk is not edited"
);
let mut h = Harness::new();
h.click_text(&mut app, "uuid");
h.frame(&mut app, key(egui::Key::End), 0.05);
h.frame(&mut app, vec![egui::Event::Text("4".into())], 0.05);
h.frame(&mut app, vec![], 0.05);
assert_eq!(expr(&app), "uuid4");
assert!(
app.session.collections[0].entries[0].modified,
"the edit was never noticed"
);
h.frame(&mut app, key(egui::Key::Backspace), 0.05);
h.frame(&mut app, vec![], 0.05);
assert_eq!(expr(&app), "uuid");
assert!(
!app.session.collections[0].entries[0].modified,
"the request matches the file again, so the pencil should be gone"
);
}
#[test]
fn the_list_follows_the_word_the_caret_is_in() {
let mut app = app_with_row("v", "base64(up");
let mut h = Harness::new();
h.click_text(&mut app, "base64(up");
h.frame(&mut app, key(egui::Key::End), 0.05);
let painted = h.frame(&mut app, vec![], 0.05);
let texts: Vec<&String> = painted.iter().map(|(t, _)| t).collect();
assert!(
texts.iter().any(|t| t.contains("upper(")),
"the word under the caret should drive the list: {texts:?}"
);
}
}
#[cfg(test)]
mod assert_layout_tests {
use super::computed_cell_undo_tests::Harness;
use super::*;
fn app_with_asserts(rows: &[&str]) -> GuiApp {
let mut session = crate::session::Session::default();
let mut entry = HurlEntry::default();
entry.method = "GET".into();
entry.url = "https://h/a".into();
entry.title = "Demo".into();
entry.asserts = rows.iter().map(|r| r.to_string()).collect();
session.collections[0].entries = vec![entry];
session.collections[0].selected_entry = 0;
let mut app = GuiApp::for_test(session);
app.editor_section = EditorSection::Asserts;
app
}
#[test]
fn one_assert_keeps_the_add_button_in_view() {
let mut app = app_with_asserts(&["jsonpath \"$.a\" exists"]);
let mut h = Harness::new();
let placed = h.frame(&mut app, vec![], 0.1);
let st = crate::i18n::Strings::for_language(&crate::i18n::Language::English);
let row = placed
.iter()
.find(|(t, _)| t.starts_with("jsonpath"))
.expect("the assert row should be painted")
.1;
let add = placed
.iter()
.find(|(t, _)| t == st.gui_add_assert)
.expect("the add button should be painted")
.1;
let status = placed
.iter()
.find(|(t, _)| t == st.gui_expected_status)
.expect("the status label should be painted")
.1;
assert!(
add.min.y - row.max.y < 40.0,
"the add button should follow the row: row {row:?}, add {add:?}"
);
assert!(
status.max.y < 700.0,
"the rest of the section should stay on screen: {status:?}"
);
}
#[test]
fn an_assert_starts_where_the_help_above_it_starts() {
let mut app = app_with_asserts(&["jsonpath \"$.a\" exists"]);
let mut h = Harness::new();
let placed = h.frame(&mut app, vec![], 0.1);
let st = crate::i18n::Strings::for_language(&crate::i18n::Language::English);
let help = placed
.iter()
.find(|(t, _)| t == st.gui_response_assertions)
.expect("the help line should be painted")
.1;
let row = placed
.iter()
.find(|(t, _)| t.starts_with("jsonpath"))
.expect("the assert row should be painted")
.1;
assert!(
row.min.x - help.min.x < 6.0,
"the assert field should line up with the section: help {help:?}, row {row:?}"
);
}
}