use eframe::egui;
use crate::probe::{self, Probe, Subject, Verb, subject_label, value_preview, verb_label};
use super::app::{Dialog, GuiApp};
pub(crate) struct ProbeBuilder {
pub(super) collection_id: u64,
pub(super) entry: usize,
pub(super) entry_uid: u64,
pub(super) subjects: Vec<Probe>,
pub(super) filter: String,
pub(super) chosen: Option<Probe>,
pub(super) verbs: Vec<Verb>,
pub(super) capture_name: Option<String>,
pub(super) name_required: bool,
pub(super) selection: Option<String>,
pub(super) selected: usize,
pub(super) selected_verb: usize,
pub(super) highlighted: Option<Subject>,
}
impl ProbeBuilder {
pub(super) fn visible(&self) -> Vec<&Probe> {
if self.filter.trim().is_empty() {
return self.subjects.iter().collect();
}
let needle = self.filter.trim().to_lowercase();
self.subjects
.iter()
.filter(|p| {
subject_label(&p.subject).to_lowercase().contains(&needle)
|| value_preview(p.value.as_ref(), usize::MAX)
.to_lowercase()
.contains(&needle)
})
.collect()
}
}
pub(super) fn open(app: &mut GuiApp, ctx: &egui::Context, pointed_at: Option<Probe>) {
let ci = app.active_ci();
let Some(col) = app.session.collections.get(ci) else {
return;
};
let entry = col.selected_entry;
let Some(response) = col
.entries
.get(entry)
.and_then(|e| e.last_response.as_ref())
else {
return;
};
let subjects = probe::probes(
response.status,
response.duration_ms,
&response.headers,
&response.body,
);
if subjects.is_empty() {
return;
}
let selection = body_selection(app, ctx);
let entry_uid = col.entries.get(entry).map(|e| e.uid).unwrap_or(0);
let mut builder = ProbeBuilder {
collection_id: col.id,
entry,
entry_uid,
subjects,
filter: String::new(),
chosen: None,
verbs: Vec::new(),
capture_name: None,
name_required: false,
selection: selection.clone(),
selected: 0,
selected_verb: 0,
highlighted: None,
};
if let Some(probe) = pointed_at {
builder.verbs = probe::verbs_for(&probe, selection.as_deref());
builder.chosen = Some(probe);
}
app.dialog = Some(Dialog::ProbeBuilder(Box::new(builder)));
}
pub(super) fn body_field_id(app: &GuiApp) -> egui::Id {
let (uid, idx) = app
.session
.collections
.get(app.active_ci())
.map(|c| {
(
c.entries.get(c.selected_entry).map(|e| e.uid).unwrap_or(0),
c.selected_entry,
)
})
.unwrap_or((0, 0));
egui::Id::new(("resp_body", uid, idx))
}
fn body_selection(app: &GuiApp, ctx: &egui::Context) -> Option<String> {
let state = egui::TextEdit::load_state(ctx, body_field_id(app))?;
let range = state.cursor.char_range()?.as_sorted_char_range();
if range.start.0 >= range.end.0 {
return None;
}
let body = app
.session
.collections
.get(app.active_ci())
.and_then(|c| c.entries.get(c.selected_entry))
.and_then(|e| e.last_response.as_ref())
.map(|r| r.body.clone())?;
let text = if app.response_compact {
selection_from_compacted(&body, range.start.0, range.end.0)?
} else {
body.chars()
.skip(range.start.0)
.take(range.end.0 - range.start.0)
.collect()
};
let text = text.trim().to_string();
(!text.is_empty() && !text.contains('\n')).then_some(text)
}
fn selection_from_compacted(raw: &str, start: usize, end: usize) -> Option<String> {
let (compacted, maps) = crate::shared_utils::compact_long_strings_mapped(raw);
let full_start = compacted_to_raw_offset(&compacted, &maps, raw, start);
let full_end = compacted_to_raw_offset(&compacted, &maps, raw, end);
if full_start >= full_end {
return None;
}
Some(
raw.chars()
.skip(full_start)
.take(full_end - full_start)
.collect(),
)
}
fn compacted_to_raw_offset(compacted: &str, maps: &[Vec<usize>], raw: &str, pos: usize) -> usize {
let (line, col) = flat_to_line_col(compacted, pos);
let full_col = match maps.get(line) {
Some(map) if !map.is_empty() => map[col.min(map.len() - 1)],
_ => col,
};
line_col_to_flat(raw, line, full_col)
}
fn flat_to_line_col(text: &str, offset: usize) -> (usize, usize) {
let mut line = 0;
let mut col = 0;
for (count, ch) in text.chars().enumerate() {
if count == offset {
return (line, col);
}
if ch == '\n' {
line += 1;
col = 0;
} else {
col += 1;
}
}
(line, col)
}
fn line_col_to_flat(text: &str, target_line: usize, target_col: usize) -> usize {
let mut offset = 0;
for (i, line) in text.split('\n').enumerate() {
if i == target_line {
return offset + target_col;
}
offset += line.chars().count() + 1;
}
offset
}
fn builder_body(app: &GuiApp) -> Option<std::sync::Arc<str>> {
app.session
.collections
.get(app.active_ci())
.and_then(|c| c.entries.get(c.selected_entry))
.and_then(|e| e.last_response.as_ref())
.map(|r| r.body.clone())
}
pub(super) fn highlight(app: &GuiApp, ctx: &egui::Context, subject: &Subject) {
let Some(body) = builder_body(app) else {
return;
};
let Some(range) = value_char_range(&body, subject, app.response_compact) else {
return;
};
let id = body_field_id(app);
let Some(mut state) = egui::TextEdit::load_state(ctx, id) else {
return;
};
state
.cursor
.set_char_range(Some(egui::text::CCursorRange::two(
egui::text::CCursor::new(range.0),
egui::text::CCursor::new(range.1),
)));
state.store(ctx, id);
}
fn value_char_range(body: &str, subject: &Subject, compact: bool) -> Option<(usize, usize)> {
char_range_of(body, probe::span_of(body, subject)?, compact)
}
fn key_char_range(body: &str, subject: &Subject, compact: bool) -> Option<(usize, usize)> {
char_range_of(body, probe::key_span_of(body, subject)?, compact)
}
fn char_range_of(
body: &str,
span: std::ops::Range<usize>,
compact: bool,
) -> Option<(usize, usize)> {
let start = body[..span.start].chars().count();
let end = start + body[span.clone()].chars().count();
if !compact {
return Some((start, end));
}
let (compacted, maps) = crate::shared_utils::compact_long_strings_mapped(body);
Some((
raw_to_compacted_offset(&compacted, &maps, body, start),
raw_to_compacted_offset(&compacted, &maps, body, end),
))
}
fn raw_to_compacted_offset(compacted: &str, maps: &[Vec<usize>], raw: &str, pos: usize) -> usize {
let (line, col) = flat_to_line_col(raw, pos);
let compact_col = match maps.get(line) {
Some(map) if !map.is_empty() => {
map.iter()
.rposition(|raw_col| *raw_col <= col)
.unwrap_or(map.len() - 1)
}
_ => col,
};
line_col_to_flat(compacted, line, compact_col)
}
pub(super) fn value_text(app: &GuiApp, probe: &Probe) -> Option<String> {
match builder_body(app) {
Some(body) => raw_value_text(&body, probe),
None => plain_value_text(probe),
}
}
pub(super) fn raw_value_text(body: &str, probe: &Probe) -> Option<String> {
if let Some(span) = probe::span_of(body, &probe.subject) {
return Some(body[span].to_string());
}
if matches!(probe.subject, Subject::Body) {
return Some(body.to_string());
}
plain_value_text(probe)
}
fn plain_value_text(probe: &Probe) -> Option<String> {
match probe.value.as_ref()? {
serde_json::Value::String(s) => Some(s.clone()),
v => Some(v.to_string()),
}
}
pub(super) fn pointed_in(
ctx: &egui::Context,
body: &str,
compact: bool,
id: egui::Id,
) -> Option<Probe> {
if compact {
return None;
}
let state = egui::TextEdit::load_state(ctx, id)?;
let range = state.cursor.char_range()?.as_sorted_char_range();
let offset = body
.char_indices()
.map(|(i, _)| i)
.chain(std::iter::once(body.len()))
.nth(range.start.0)?;
probe::probe_at(body, offset)
}
pub(super) fn hovered_in(
galley: &egui::Galley,
galley_pos: egui::Pos2,
pointer: egui::Pos2,
body: &str,
compact: bool,
) -> Option<Probe> {
if compact {
return None;
}
let cursor = galley.cursor_from_pos(pointer - galley_pos);
let offset = char_to_byte(body, cursor.index.0)?;
probe::probe_at(body, offset)
}
fn char_to_byte(body: &str, n: usize) -> Option<usize> {
body.char_indices()
.map(|(i, _)| i)
.chain(std::iter::once(body.len()))
.nth(n)
}
pub(super) fn paint_span(
painter: &egui::Painter,
galley: &egui::Galley,
galley_pos: egui::Pos2,
body: &str,
subject: &Subject,
compact: bool,
colour: egui::Color32,
) -> usize {
if let Some((start, end)) = key_char_range(body, subject, compact)
&& end > start
{
paint_char_range(painter, galley, galley_pos, start, end, colour);
}
let Some((start, end)) = value_char_range(body, subject, compact) else {
return 0;
};
if end <= start {
return 0;
}
paint_char_range(painter, galley, galley_pos, start, end, colour)
}
fn paint_char_range(
painter: &egui::Painter,
galley: &egui::Galley,
galley_pos: egui::Pos2,
start: usize,
end: usize,
colour: egui::Color32,
) -> usize {
let a = galley.layout_from_cursor(egui::text::CCursor::new(start));
let b = galley.layout_from_cursor(egui::text::CCursor::new(end));
let mut painted = 0;
for r in a.row..=b.row {
let Some(row) = galley.rows.get(r) else { break };
let left = if r == a.row {
galley.pos_from_layout_cursor(&a).left()
} else {
row.rect().left()
};
let right = if r == b.row {
galley.pos_from_layout_cursor(&b).left()
} else {
row.rect().right()
};
if right <= left {
continue;
}
let rect = egui::Rect::from_min_max(
egui::pos2(left, row.rect().top()),
egui::pos2(right, row.rect().bottom()),
)
.translate(galley_pos.to_vec2());
painter.rect_filled(rect, 2.0, colour);
painted += 1;
}
painted
}
pub(super) fn apply(app: &mut GuiApp, builder: &ProbeBuilder, verb: &Verb, name: &str) -> bool {
let Some(probe) = builder.chosen.as_ref() else {
return false;
};
let Some(ci) = app
.session
.collections
.iter()
.position(|c| c.id == builder.collection_id)
else {
return false;
};
let Some(col) = app.session.collections.get_mut(ci) else {
return false;
};
let Some(target) = find_entry_mut(col, builder.entry_uid, builder.entry) else {
return false;
};
let changed = match verb {
Verb::ExpectStatus(code) => {
let fresh = target.expected_status != Some(*code);
target.expected_status = Some(*code);
fresh
}
Verb::Assert(predicate) => {
match probe::assert_line(probe.subject.clone(), predicate.clone()) {
Some(line) if !target.asserts.contains(&line) => {
target.asserts.push(line);
true
}
_ => false,
}
}
Verb::Capture => {
let name = name.trim();
if name.is_empty() {
return false;
}
let row = probe::capture_row(&probe.subject, name);
match target.captures.iter_mut().find(|(n, _)| n == &row.0) {
Some(existing) if existing.1 == row.1 => false,
Some(existing) => {
existing.1 = row.1;
true
}
None => {
target.captures.push(row);
true
}
}
}
};
if changed {
target.mark_edited();
col.invalidate_request_json();
app.session.save();
}
changed
}
fn find_entry_mut(
col: &mut crate::collection::Collection,
uid: u64,
idx: usize,
) -> Option<&mut crate::hurl::HurlEntry> {
if uid != 0 {
return match col.entries.iter().filter(|e| e.uid == uid).count() {
1 => col.entries.iter_mut().find(|e| e.uid == uid),
_ => None,
};
}
col.entries.get_mut(idx)
}
fn find_entry<'a>(
col: &'a crate::collection::Collection,
uid: u64,
idx: usize,
) -> Option<&'a crate::hurl::HurlEntry> {
if uid != 0 {
return match col.entries.iter().filter(|e| e.uid == uid).count() {
1 => col.entries.iter().find(|e| e.uid == uid),
_ => None,
};
}
col.entries.get(idx)
}
pub(super) fn suggested_name(app: &GuiApp, builder: &ProbeBuilder, subject: &Subject) -> String {
let taken: Vec<String> = app
.session
.collections
.iter()
.find(|c| c.id == builder.collection_id)
.and_then(|c| find_entry(c, builder.entry_uid, builder.entry))
.map(|e| e.captures.iter().map(|(n, _)| n.clone()).collect())
.unwrap_or_default();
probe::suggest_name(subject, &taken)
}
pub(super) fn subject_row(probe: &Probe) -> String {
let value = value_preview(probe.value.as_ref(), 48);
if value.is_empty() {
subject_label(&probe.subject)
} else {
format!("{} {value}", subject_label(&probe.subject))
}
}
pub(super) fn verb_row(subject: &Subject, verb: &Verb, s: &crate::i18n::Strings) -> String {
verb_label(subject, verb, s)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hurl::HurlEntry;
fn app_with_response(body: &str) -> GuiApp {
let mut entry = HurlEntry {
title: "Login".to_string(),
..Default::default()
};
entry.last_response = Some(crate::http::ApiResponse {
status: 201,
body: std::sync::Arc::from(body),
headers: vec![("Content-Type".into(), "application/json".into())],
duration_ms: Some(90),
..Default::default()
});
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 builder(app: &GuiApp, body: &str) -> ProbeBuilder {
ProbeBuilder {
collection_id: app.session.collections[0].id,
entry: 0,
entry_uid: app.session.collections[0].entries[0].uid,
subjects: probe::probes(201, Some(90), &[], body),
filter: String::new(),
chosen: None,
verbs: Vec::new(),
capture_name: None,
name_required: false,
selection: None,
selected: 0,
selected_verb: 0,
highlighted: None,
}
}
#[test]
fn choosing_a_value_and_a_verb_writes_the_assert() {
let body = r#"{"token":"abc"}"#;
let mut app = app_with_response(body);
let mut b = builder(&app, body);
let probe = b
.subjects
.iter()
.find(|p| subject_label(&p.subject) == "$.token")
.unwrap()
.clone();
b.verbs = probe::verbs_for(&probe, None);
b.chosen = Some(probe);
assert!(apply(&mut app, &b, &b.verbs[0], ""));
let entry = &app.session.collections[0].entries[0];
assert_eq!(entry.asserts, [r#"jsonpath "$.token" == "abc""#]);
assert!(entry.modified);
assert!(!apply(&mut app, &b, &b.verbs[0], ""));
assert_eq!(app.session.collections[0].entries[0].asserts.len(), 1);
}
#[test]
fn capturing_writes_a_row_under_the_typed_name() {
let body = r#"{"data":{"access_token":"ey.."}}"#;
let mut app = app_with_response(body);
let mut b = builder(&app, body);
let probe = b
.subjects
.iter()
.find(|p| subject_label(&p.subject) == "$.data.access_token")
.unwrap()
.clone();
assert_eq!(suggested_name(&app, &b, &probe.subject), "access_token");
b.chosen = Some(probe);
assert!(apply(&mut app, &b, &Verb::Capture, "tok"));
assert_eq!(
app.session.collections[0].entries[0].captures,
[(
"tok".to_string(),
"jsonpath \"$.data.access_token\"".to_string()
)]
);
}
#[test]
fn the_status_sets_the_http_line() {
let mut app = app_with_response("{}");
let mut b = builder(&app, "{}");
let probe = b
.subjects
.iter()
.find(|p| matches!(p.subject, Subject::Status))
.unwrap()
.clone();
b.verbs = probe::verbs_for(&probe, None);
b.chosen = Some(probe);
assert!(apply(&mut app, &b, &b.verbs[0], ""));
let entry = &app.session.collections[0].entries[0];
assert_eq!(entry.expected_status, Some(201));
assert!(entry.asserts.is_empty());
}
#[test]
fn the_filter_matches_the_path_or_the_value() {
let body = r#"{"token":"abcdef","user":{"id":7}}"#;
let app = app_with_response(body);
let mut b = builder(&app, body);
b.filter = "abcdef".into();
assert_eq!(
b.visible()
.iter()
.map(|p| subject_label(&p.subject))
.collect::<Vec<_>>(),
["$.token"]
);
b.filter = "user".into();
assert!(
b.visible()
.iter()
.any(|p| subject_label(&p.subject) == "$.user.id")
);
}
#[test]
fn a_closed_collection_is_not_written_to() {
let body = r#"{"a":1}"#;
let mut app = app_with_response(body);
let mut b = builder(&app, body);
let probe = b
.subjects
.iter()
.find(|p| subject_label(&p.subject) == "$.a")
.unwrap()
.clone();
b.verbs = probe::verbs_for(&probe, None);
b.chosen = Some(probe);
b.collection_id = u64::MAX;
assert!(!apply(&mut app, &b, &b.verbs[0], ""));
assert!(app.session.collections[0].entries[0].asserts.is_empty());
}
#[test]
fn a_row_shows_the_value_beside_the_path() {
let row = subject_row(&Probe {
subject: Subject::Json("$.token".into()),
value: Some(serde_json::json!("abcdef")),
});
assert!(row.contains("$.token"), "{row}");
assert!(row.contains("abcdef"), "{row}");
}
}
#[cfg(test)]
mod paint_tests {
use super::*;
use crate::gui::app::Dialog;
use crate::gui::probe_test_support::*;
use crate::hurl::HurlEntry;
use eframe::egui;
#[test]
fn the_caret_is_a_char_index_and_the_conversion_is_load_bearing() {
let body = "{\n \"note\": \"héllo wörld 🚀 中文中文\",\n \"token\": \"abcdef\"\n}";
let target_at = body.find("abcdef").unwrap();
let chars_before = body[..target_at].chars().count();
assert!(
target_at > chars_before,
"the fixture must have multi-byte text before the target ({target_at} bytes, {chars_before} chars)"
);
let mut app = app_with(body, vec![], 200);
let ctx = themed_ctx();
panel_frame(&mut app, &ctx, vec![]);
let painted = panel_frame(&mut app, &ctx, vec![]);
let (pos, galley) = painted
.iter()
.find(|(_, g)| g.text().contains("abcdef"))
.expect("the body was never painted");
let aim = body[..target_at + 2].chars().count();
let target = *pos
+ galley
.pos_from_cursor(egui::text::CCursor::new(aim))
.center()
.to_vec2();
let (press, release) = click_events(target, egui::PointerButton::Secondary);
panel_frame(&mut app, &ctx, press);
panel_frame(&mut app, &ctx, release);
let id = body_field_id(&app);
let range = egui::TextEdit::load_state(&ctx, id)
.and_then(|s| s.cursor.char_range())
.expect("the click left no caret");
let index = range.as_sorted_char_range().start.0;
assert_eq!(
index, aim,
"egui handed back a byte offset, not a character index"
);
let resolved = pointed_in(&ctx, body, false, id);
let as_bytes = crate::probe::probe_at(body, index);
assert_ne!(
format!("{resolved:?}"),
format!("{as_bytes:?}"),
"the two readings agree on this fixture, so it proves nothing"
);
let subject = resolved.expect("nothing under the caret").subject;
assert_eq!(
crate::probe::subject_label(&subject),
"$.token",
"clicked inside \"abcdef\" but the builder targeted a different field"
);
}
#[test]
fn a_selection_made_in_the_compacted_view_quotes_the_wrong_text() {
let raw = format!(r#"{{"a":"{}","tail":"WANTED"}}"#, "x".repeat(80));
let mut app = app_with(&raw, vec![], 200);
app.response_compact = true;
let ctx = themed_ctx();
panel_frame(&mut app, &ctx, vec![]);
let painted = panel_frame(&mut app, &ctx, vec![]);
let shown = crate::shared_utils::compact_long_strings(&raw);
let (pos, galley) = painted
.iter()
.find(|(_, g)| g.text() == shown)
.unwrap_or_else(|| {
panic!(
"the compacted body was never painted: {:?}",
texts(&painted)
)
});
let at = shown.find("WANTED").unwrap();
let start = shown[..at].chars().count();
let p = |i: usize| {
*pos + galley
.pos_from_cursor(egui::text::CCursor::new(i))
.center()
.to_vec2()
};
let (from, to) = (p(start), p(start + "WANTED".len()));
let down = egui::Event::PointerButton {
pos: from,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: Default::default(),
};
let up = egui::Event::PointerButton {
pos: to,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: Default::default(),
};
panel_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), down]);
panel_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]);
panel_frame(&mut app, &ctx, vec![up]);
let selected: String = {
let range = egui::TextEdit::load_state(&ctx, body_field_id(&app))
.and_then(|s| s.cursor.char_range())
.expect("no selection")
.as_sorted_char_range();
shown
.chars()
.skip(range.start.0)
.take(range.end.0 - range.start.0)
.collect()
};
open(&mut app, &ctx, None);
let Some(Dialog::ProbeBuilder(b)) = &app.dialog else {
panic!("the builder did not open");
};
assert_eq!(
b.selection.as_deref(),
Some(selected.trim()),
"the `body contains` literal is not the text that was selected"
);
}
#[test]
fn a_selection_survives_into_the_next_request_and_quotes_its_body_instead() {
let first = r#"{"one":"AAAAAAAAAA","two":"SELECTME"}"#;
let second = r#"{"one":"BBBBBBBBBB","two":"different"}"#;
let mut app = app_with(first, vec![], 200);
let mut other = HurlEntry {
title: "Other".to_string(),
..Default::default()
};
other.last_response = Some(crate::http::ApiResponse {
status: 200,
body: std::sync::Arc::from(second),
..Default::default()
});
app.session.collections[0].entries.push(other);
let ctx = themed_ctx();
panel_frame(&mut app, &ctx, vec![]);
let painted = panel_frame(&mut app, &ctx, vec![]);
let (pos, galley) = painted
.iter()
.find(|(_, g)| g.text() == first)
.expect("body not painted");
let at = first.find("SELECTME").unwrap();
let p = |i: usize| {
*pos + galley
.pos_from_cursor(egui::text::CCursor::new(i))
.center()
.to_vec2()
};
let (from, to) = (p(at), p(at + "SELECTME".len()));
let down = egui::Event::PointerButton {
pos: from,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: Default::default(),
};
let up = egui::Event::PointerButton {
pos: to,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: Default::default(),
};
panel_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), down]);
panel_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]);
panel_frame(&mut app, &ctx, vec![up]);
app.session.collections[0].selected_entry = 1;
panel_frame(&mut app, &ctx, vec![]);
open(&mut app, &ctx, None);
let Some(Dialog::ProbeBuilder(b)) = &app.dialog else {
panic!("the builder did not open");
};
assert_eq!(
b.selection, None,
"a selection made in another request's body was offered as this one's literal"
);
}
#[test]
fn reordering_requests_while_the_builder_is_open_writes_the_assert_elsewhere() {
let body = r#"{"token":"abc"}"#;
let mut app = app_with(body, vec![], 200);
let first = HurlEntry {
title: "Ping".to_string(),
..Default::default()
};
app.session.collections[0].entries.insert(0, first);
app.session.collections[0].selected_entry = 1;
let ctx = themed_ctx();
let target = crate::probe::probes(200, None, &[], body)
.into_iter()
.find(|p| crate::probe::subject_label(&p.subject) == "$.token")
.unwrap();
open(&mut app, &ctx, Some(target));
let Some(Dialog::ProbeBuilder(b)) = app.dialog.take() else {
panic!("no builder");
};
app.session.collections[0].entries.swap(0, 1);
let verb = b.verbs[0].clone();
apply(&mut app, &b, &verb, "");
let ping = &app.session.collections[0].entries[1];
assert!(
ping.asserts.is_empty(),
"the assert landed on {:?}, which never made that request",
ping.title
);
}
}