use std::path::PathBuf;
use eframe::egui::{self, RichText};
use crate::collection::WsRow;
use crate::hurl::{HurlEntry, RunStatus};
use crate::tree::entry_path;
use super::app::{Dialog, GuiApp, RenameTarget};
use super::theme::GuiTheme;
const SCROLLBAR_GUTTER: f32 = 10.0;
fn clickable_row(
ui: &mut egui::Ui,
id: impl std::hash::Hash + std::fmt::Debug,
content: impl FnOnce(&mut egui::Ui) -> egui::Response,
) -> egui::Response {
let bg = ui.painter().add(egui::Shape::Noop);
let drawn = ui.push_id(id, content);
let rect = drawn.response.rect;
let hit = ui.interact(
rect,
drawn.response.id.with("whole_row"),
egui::Sense::click_and_drag(),
);
if hit.hovered() || drawn.inner.hovered() {
let visuals = ui.visuals().widgets.hovered;
ui.painter().set(
bg,
egui::epaint::RectShape::filled(rect, visuals.corner_radius, visuals.weak_bg_fill),
);
}
drawn.inner | hit
}
fn run_marker(status: RunStatus) -> (&'static str, bool) {
match status {
RunStatus::Passed => (super::icons::PASS, true),
RunStatus::Failed => (super::icons::FAIL, false),
RunStatus::Running => (super::icons::RUNNING, true),
RunStatus::NotRun => ("", true),
}
}
fn edited_marker(ui: &mut egui::Ui, entry: &HurlEntry, theme: &GuiTheme, tip: &str) {
if !(entry.user_added || entry.modified) {
return;
}
ui.colored_label(theme.pending, super::icons::EDITED)
.on_hover_text(tip);
}
#[derive(Default)]
struct Node {
children: Vec<Child>,
}
enum Child {
Folder(String, Node),
Entry(usize),
}
pub(super) fn nav_entry_order(col: &crate::collection::Collection) -> Vec<usize> {
if col.list_filter_active() {
col.rows()
.into_iter()
.filter_map(|r| match r {
crate::tree::Row::Entry(i) => Some(i),
_ => None,
})
.collect()
} else {
let tree = build_tree(&col.entries, col.list_sort);
let mut out = Vec::with_capacity(col.entries.len());
collect_entry_order(&tree, &mut out);
out
}
}
fn collect_entry_order(node: &Node, out: &mut Vec<usize>) {
for child in &node.children {
match child {
Child::Folder(_, sub) => collect_entry_order(sub, out),
Child::Entry(i) => out.push(*i),
}
}
}
fn build_tree(entries: &[HurlEntry], sort: crate::tree::SortMode) -> Node {
let mut root = Node::default();
for (i, e) in entries.iter().enumerate() {
let path = entry_path(&e.title);
let (folders, _leaf) = path.split_at(path.len() - 1);
insert(&mut root, folders, i);
}
sort_node(&mut root, entries, sort);
root
}
fn sort_node(node: &mut Node, entries: &[HurlEntry], sort: crate::tree::SortMode) {
let name = |child: &Child| match child {
Child::Folder(n, _) => n.clone(),
Child::Entry(i) => entries
.get(*i)
.map(crate::tree::leaf_name)
.unwrap_or_default(),
};
node.children
.sort_by(|a, b| crate::tree::cmp_names(sort, &name(a), &name(b)));
for child in &mut node.children {
if let Child::Folder(_, sub) = child {
sort_node(sub, entries, sort);
}
}
}
fn insert(node: &mut Node, folders: &[String], i: usize) {
let Some((head, rest)) = folders.split_first() else {
node.children.push(Child::Entry(i));
return;
};
let existing = node
.children
.iter_mut()
.find(|c| matches!(c, Child::Folder(name, _) if name == head));
if let Some(Child::Folder(_, sub)) = existing {
insert(sub, rest, i);
return;
}
let mut sub = Node::default();
insert(&mut sub, rest, i);
node.children.push(Child::Folder(head.clone(), sub));
}
#[derive(Default)]
struct Actions {
select: Option<usize>,
run: Option<usize>,
rename: Option<usize>,
duplicate: Option<usize>,
delete: Option<usize>,
revert: Option<usize>,
reorder: Option<(usize, usize)>,
}
#[derive(Clone, Debug)]
struct ReqDrag(usize);
struct RowLabels<'a> {
untitled: &'a str,
run: &'a str,
rename: &'a str,
duplicate: &'a str,
delete: &'a str,
edited: &'a str,
revert: Option<&'a str>,
}
#[allow(clippy::too_many_arguments)]
fn render_node(
ui: &mut egui::Ui,
node: &Node,
entries: &[HurlEntry],
selected: usize,
reveal: bool,
theme: &GuiTheme,
id_prefix: &str,
labels: &RowLabels<'_>,
reorderable: bool,
actions: &mut Actions,
) {
for child in &node.children {
match child {
Child::Folder(name, sub) => {
let salt = format!("{id_prefix}/{name}");
super::widgets::tree_header(
ui,
&salt,
true,
RichText::new(format!("{} {name}", super::icons::FOLDER)).color(theme.text),
|ui| {
render_node(
ui,
sub,
entries,
selected,
reveal,
theme,
&salt,
labels,
reorderable,
actions,
);
},
);
}
Child::Entry(i) => {
let i = *i;
let leaf = crate::tree::leaf_name(&entries[i]);
render_entry_row(
ui,
i,
entries,
leaf,
selected,
reveal,
theme,
labels,
reorderable,
actions,
);
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn render_entry_row(
ui: &mut egui::Ui,
i: usize,
entries: &[HurlEntry],
label: String,
selected: usize,
reveal: bool,
theme: &GuiTheme,
labels: &RowLabels<'_>,
reorderable: bool,
actions: &mut Actions,
) {
{
let entry = &entries[i];
let label = if label.trim().is_empty() {
if entry.url.trim().is_empty() {
labels.untitled.to_string()
} else {
entry.url.clone()
}
} else {
label
};
let (marker, ok) = run_marker(entry.last_run);
let is_sel = i == selected;
let row = clickable_row(ui, ("req_row", i), |ui| {
ui.horizontal(|ui| {
super::widgets::method_badge(ui, theme, &entry.method);
let text = if is_sel {
RichText::new(&label).strong().color(theme.text)
} else {
RichText::new(&label).color(theme.text)
};
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(SCROLLBAR_GUTTER);
if !marker.is_empty() {
let mc = if ok { theme.ok } else { theme.err };
ui.colored_label(mc, marker);
}
edited_marker(ui, entry, theme, labels.edited);
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
super::widgets::selectable_row(ui, is_sel, text)
})
.inner
})
.inner
})
.inner
});
if row.clicked() {
actions.select = Some(i);
}
if reveal && is_sel {
row.scroll_to_me(None);
}
if row.double_clicked() {
actions.run = Some(i);
}
row.context_menu(|ui| {
if ui.button(labels.run).clicked() {
actions.run = Some(i);
ui.close();
}
if ui.button(labels.rename).clicked() {
actions.rename = Some(i);
ui.close();
}
if ui.button(labels.duplicate).clicked() {
actions.duplicate = Some(i);
ui.close();
}
if ui.button(labels.delete).clicked() {
actions.delete = Some(i);
ui.close();
}
if let Some(revert) = labels.revert
&& entries[i].modified
&& ui.button(revert).clicked()
{
actions.revert = Some(i);
ui.close();
}
});
if reorderable {
if row.drag_started() {
egui::DragAndDrop::set_payload(ui.ctx(), ReqDrag(i));
}
if row.dragged()
&& let Some(pos) = ui.ctx().pointer_interact_pos()
{
let layer =
egui::LayerId::new(egui::Order::Tooltip, ui.id().with("req_drag_label"));
ui.ctx().layer_painter(layer).text(
pos + egui::vec2(12.0, 4.0),
egui::Align2::LEFT_TOP,
&label,
egui::TextStyle::Button.resolve(ui.style()),
theme.accent,
);
}
request_drop_zone(ui, &row, theme, i, actions);
}
}
}
fn request_drop_zone(
ui: &mut egui::Ui,
resp: &egui::Response,
theme: &GuiTheme,
i: usize,
actions: &mut Actions,
) {
let Some(dragged) = egui::DragAndDrop::payload::<ReqDrag>(ui.ctx()) else {
return;
};
if !resp.contains_pointer() {
return;
}
let Some(pos) = ui.ctx().pointer_interact_pos() else {
return;
};
let rect = resp.rect;
let above = pos.y < rect.center().y;
let before = if above { i } else { i + 1 };
if dragged.0 == before || dragged.0 + 1 == before {
return;
}
let y = if above { rect.top() } else { rect.bottom() };
ui.painter()
.hline(rect.x_range(), y, egui::Stroke::new(2.0, theme.accent));
if ui.input(|i| i.pointer.any_released()) {
actions.reorder = Some((dragged.0, before));
egui::DragAndDrop::clear_payload(ui.ctx());
}
}
pub(super) fn run_all_confirm_counts(entries: &[HurlEntry]) -> (usize, usize) {
let non_get = entries
.iter()
.filter(|e| !e.method.eq_ignore_ascii_case("GET"))
.count();
(entries.len(), non_get)
}
pub fn ui(app: &mut GuiApp, ui: &mut egui::Ui) {
let ci = app.active_ci();
if app.session.collections[ci].is_workspace() {
workspace_ui(app, ui, ci);
return;
}
let theme = app.theme;
let s = &app.strings;
let (
lbl_run_all,
tip_run_all,
tip_add,
default_title,
lbl_untitled,
lbl_no_requests,
lbl_run,
lbl_rename,
lbl_duplicate,
lbl_delete,
lbl_edited,
) = (
s.gui_run_all,
s.gui_run_all_tooltip,
s.gui_add_request,
s.gui_new_request,
s.gui_untitled_request,
s.gui_no_requests_tree,
s.gui_run,
s.gui_rename_ellipsis,
s.gui_duplicate,
s.gui_delete,
s.gui_edited_request,
);
let lbl_revert = s.gui_ws_revert_request;
let (lbl_import, lbl_import_file, lbl_import_account, tip_import_file, tip_import_account) = (
s.gui_import_postman_button,
s.gui_menu_import_file,
s.gui_menu_import_account,
s.help_menu_import_file,
s.help_menu_import_account,
);
let (hint_filter, lbl_filter_no_matches) =
(s.gui_request_filter_hint, s.gui_request_filter_no_matches);
let (lbl_sort_file, lbl_sort_alpha, lbl_sort_reverse, tip_sort) = (
s.gui_sort_file,
s.gui_sort_alpha,
s.gui_sort_reverse,
s.help_sort_button,
);
let name = app.session.collections[ci].name.clone();
super::widgets::panel_header(ui, &theme, name, |ui| {
let run_all = format!("{} {}", super::icons::PLAY, lbl_run_all);
if ui.button(run_all).on_hover_text(tip_run_all).clicked() {
app.request_run_all(ci);
}
if ui
.button(super::icons::PLUS)
.on_hover_text(tip_add)
.clicked()
{
let mut e = HurlEntry::default();
e.method = "GET".into();
e.url = app.session.vars.base_url.clone();
e.title = default_title.into();
e.user_added = true;
let col = &mut app.session.collections[ci];
col.entries.push(e);
col.selected_entry = col.entries.len() - 1;
col.invalidate_request_json();
}
});
ui.separator();
if app.session.collections[ci].entries.len() > 1 {
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let sort = app.session.collections[ci].list_sort;
let (icon, label) = match sort {
crate::tree::SortMode::File => (super::icons::SORT_FILE, lbl_sort_file),
crate::tree::SortMode::Alpha => (super::icons::SORT_ASC, lbl_sort_alpha),
crate::tree::SortMode::ReverseAlpha => {
(super::icons::SORT_DESC, lbl_sort_reverse)
}
};
if ui
.button(icon)
.on_hover_text(format!("{label}\n\n{tip_sort}"))
.clicked()
{
app.session.collections[ci].list_sort = sort.next();
}
super::widgets::flat_fields(ui, |ui| {
ui.add(
egui::TextEdit::singleline(&mut app.session.collections[ci].list_query)
.hint_text(hint_filter)
.desired_width(f32::INFINITY),
)
});
});
});
ui.separator();
}
let selected = app.session.collections[ci].selected_entry;
let reveal = app.reveal_selected;
let filtering = app.session.collections[ci].list_filter_active();
let sort = app.session.collections[ci].list_sort;
let query = app.session.collections[ci].list_query.clone();
let on_disk = app.session.collections[ci].path.is_some();
let mut actions = Actions::default();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
super::widgets::tree_rhythm(ui);
let entries = &app.session.collections[ci].entries;
if entries.is_empty() {
ui.add_space(8.0);
ui.colored_label(theme.dim, lbl_no_requests);
ui.add_space(6.0);
ui.menu_button(lbl_import, |ui| {
if ui
.button(lbl_import_file)
.on_hover_text(tip_import_file)
.clicked()
{
super::menu::open_via_picker(app, super::app::OpenKind::PostmanExport);
ui.close();
}
if ui
.button(lbl_import_account)
.on_hover_text(tip_import_account)
.clicked()
{
app.postman.open();
ui.close();
}
});
return;
}
let tree = build_tree(entries, sort);
let labels = RowLabels {
untitled: lbl_untitled,
run: lbl_run,
rename: lbl_rename,
duplicate: lbl_duplicate,
delete: lbl_delete,
edited: lbl_edited,
revert: on_disk.then_some(lbl_revert),
};
if filtering {
let mut matches = crate::tree::rows_matching(entries, &query);
crate::tree::sort_rows(&mut matches, entries, sort);
if matches.is_empty() {
ui.add_space(8.0);
ui.colored_label(theme.dim, lbl_filter_no_matches);
return;
}
for row in matches {
let crate::tree::Row::Entry(i) = row else {
continue;
};
let title = entries[i].title.trim().to_string();
render_entry_row(
ui,
i,
entries,
title,
selected,
reveal,
&theme,
&labels,
false,
&mut actions,
);
}
return;
}
render_node(
ui,
&tree,
entries,
selected,
reveal,
&theme,
"req",
&labels,
sort == crate::tree::SortMode::File,
&mut actions,
);
});
app.reveal_selected = false;
apply_actions(app, ci, actions);
}
fn apply_actions(app: &mut GuiApp, ci: usize, actions: Actions) {
if let Some(i) = actions.select {
let col = &mut app.session.collections[ci];
col.selected_entry = i;
col.list_cursor = i;
col.invalidate_request_json();
app.focus = super::Focus::List;
}
if let Some(i) = actions.rename {
let title = app.session.collections[ci].entries[i].title.clone();
app.dialog = Some(Dialog::Rename {
target: RenameTarget::Request { ci, idx: i },
text: title,
});
}
if let Some(i) = actions.duplicate {
let col = &mut app.session.collections[ci];
if let Some(mut clone) = col.entries.get(i).cloned() {
clone.title = crate::collection::unique_entry_title(&col.entries, &clone.title);
clone.user_added = true;
clone.modified = true;
clone.last_response = None;
col.entries.insert(i + 1, clone);
col.selected_entry = i + 1;
col.invalidate_request_json();
}
}
if let Some(i) = actions.revert
&& let Some(path) = app.session.collections[ci].path.clone()
{
ask_revert_request(app, ci, path, i);
}
if let Some(i) = actions.delete {
app.request_delete_request(ci, i);
}
if let Some((from, before)) = actions.reorder {
if app.session.collections[ci].move_entry_before(from, before) {
app.session.save();
}
}
if let Some(i) = actions.run {
app.session.collections[ci].selected_entry = i;
app.run_active();
}
}
fn ask_revert_request(app: &mut GuiApp, ci: usize, path: PathBuf, i: usize) {
if !app.session.collections[ci].has_saved_version(i) {
app.session.status = Some(crate::i18n::Status::RequestHasNoSavedVersion);
return;
}
let name = app.session.collections[ci]
.entries
.get(i)
.map(|e| {
let leaf = crate::tree::entry_path(&e.title).pop().unwrap_or_default();
if leaf.is_empty() { e.url.clone() } else { leaf }
})
.unwrap_or_default();
app.dialog = Some(Dialog::RevertToSaved {
ci,
path,
entry: Some(i),
name,
});
}
const WS_INDENT: f32 = 14.0;
const WS_PAGE_JUMP: usize = 10;
enum WsAction {
ToggleFolder(PathBuf),
ToggleCollection {
path: PathBuf,
open: bool,
},
SelectRequest {
collection: PathBuf,
idx: usize,
loaded: bool,
},
RunRequest {
collection: PathBuf,
idx: usize,
loaded: bool,
},
OpenReport(PathBuf),
OpenEnv {
path: PathBuf,
reveal: bool,
},
ActivateEnv(PathBuf),
NewItem {
dir: PathBuf,
kind: crate::workspace::NewItemKind,
},
RevertRequest {
collection: PathBuf,
idx: usize,
},
RevertFile(PathBuf),
MoveItem {
src: PathBuf,
dest_dir: PathBuf,
},
RenameItem {
path: PathBuf,
},
DeleteItem {
path: PathBuf,
is_dir: bool,
},
RequestAction {
collection: PathBuf,
idx: usize,
loaded: bool,
kind: ReqEdit,
},
ReorderRequest {
collection: PathBuf,
from: usize,
before: usize,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ReqEdit {
Rename,
Duplicate,
Delete,
}
#[derive(Clone, Debug)]
struct WsDrag(PathBuf);
#[derive(Clone, Debug)]
struct WsReqDrag {
collection: PathBuf,
idx: usize,
}
fn ws_row(ui: &mut egui::Ui, depth: usize, selected: bool, text: RichText) -> egui::Response {
ui.horizontal(|ui| {
ui.add_space(depth as f32 * WS_INDENT);
ui.add(
egui::Button::selectable(selected, text)
.frame_when_inactive(true)
.sense(egui::Sense::click_and_drag()),
)
})
.inner
}
fn ws_cursor_decoration(
ui: &egui::Ui,
resp: &egui::Response,
show_ring: bool,
reveal: bool,
theme: &super::theme::GuiTheme,
) {
if show_ring {
ui.painter().rect_stroke(
resp.rect,
4.0,
egui::Stroke::new(1.0, theme.accent),
egui::StrokeKind::Inside,
);
}
if reveal {
resp.scroll_to_me(None);
}
}
fn ws_drag_and_drop(
ui: &mut egui::Ui,
resp: &egui::Response,
theme: &super::theme::GuiTheme,
path: &std::path::Path,
is_folder: bool,
actions: &mut Vec<WsAction>,
) {
if resp.drag_started() {
egui::DragAndDrop::set_payload(ui.ctx(), WsDrag(path.to_path_buf()));
}
if resp.dragged()
&& let Some(pos) = ui.ctx().pointer_interact_pos()
{
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let layer = egui::LayerId::new(egui::Order::Tooltip, ui.id().with("ws_drag_label"));
ui.ctx().layer_painter(layer).text(
pos + egui::vec2(12.0, 4.0),
egui::Align2::LEFT_TOP,
name,
egui::TextStyle::Button.resolve(ui.style()),
theme.accent,
);
}
if !is_folder {
return;
}
let Some(dragged) = egui::DragAndDrop::payload::<WsDrag>(ui.ctx()) else {
return;
};
if path.starts_with(&dragged.0) || !resp.contains_pointer() {
return;
}
ui.painter().rect_stroke(
resp.rect,
6.0,
egui::Stroke::new(1.0, theme.accent),
egui::StrokeKind::Inside,
);
if ui.input(|i| i.pointer.any_released()) {
actions.push(WsAction::MoveItem {
src: dragged.0.clone(),
dest_dir: path.to_path_buf(),
});
egui::DragAndDrop::clear_payload(ui.ctx());
}
}
fn ws_request_drop_zone(
ui: &mut egui::Ui,
resp: &egui::Response,
theme: &super::theme::GuiTheme,
collection: &std::path::Path,
i: usize,
actions: &mut Vec<WsAction>,
) {
let Some(dragged) = egui::DragAndDrop::payload::<WsReqDrag>(ui.ctx()) else {
return;
};
if dragged.collection.as_path() != collection {
return;
}
if !resp.contains_pointer() {
return;
}
let Some(pos) = ui.ctx().pointer_interact_pos() else {
return;
};
let rect = resp.rect;
let above = pos.y < rect.center().y;
let before = if above { i } else { i + 1 };
if dragged.idx == before || dragged.idx + 1 == before {
return;
}
let y = if above { rect.top() } else { rect.bottom() };
ui.painter()
.hline(rect.x_range(), y, egui::Stroke::new(2.0, theme.accent));
if ui.input(|i| i.pointer.any_released()) {
actions.push(WsAction::ReorderRequest {
collection: collection.to_path_buf(),
from: dragged.idx,
before,
});
egui::DragAndDrop::clear_payload(ui.ctx());
}
}
pub(super) fn handle_ws_tree_keys(app: &mut GuiApp, ctx: &egui::Context, ci: usize) {
let rows = app.session.collections[ci].ws_rows();
if rows.is_empty() {
return;
}
let last = rows.len() - 1;
let cur = app.session.collections[ci].list_cursor.min(last);
let (up, down, home, end, pgup, pgdn, left, right, enter, f2, del) = ctx.input_mut(|i| {
(
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp),
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown),
i.consume_key(egui::Modifiers::NONE, egui::Key::Home),
i.consume_key(egui::Modifiers::NONE, egui::Key::End),
i.consume_key(egui::Modifiers::NONE, egui::Key::PageUp),
i.consume_key(egui::Modifiers::NONE, egui::Key::PageDown),
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft),
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight),
i.consume_key(egui::Modifiers::NONE, egui::Key::Enter),
i.consume_key(egui::Modifiers::NONE, egui::Key::F2),
i.consume_key(egui::Modifiers::NONE, egui::Key::Delete),
)
});
if !(up || down || home || end || pgup || pgdn || left || right || enter || f2 || del) {
return; }
let mut new_cursor = if up {
cur.saturating_sub(1)
} else if down {
(cur + 1).min(last)
} else if home {
0
} else if end {
last
} else if pgup {
cur.saturating_sub(WS_PAGE_JUMP)
} else if pgdn {
(cur + WS_PAGE_JUMP).min(last)
} else {
cur
};
let row = &rows[cur];
let mut action: Option<WsAction> = None;
if right {
match ws_expansion(row) {
Some(false) => action = ws_toggle_action(row),
Some(true) => {
if cur < last && rows[cur + 1].depth() > row.depth() {
new_cursor = cur + 1;
}
}
None => {}
}
} else if left {
match ws_expansion(row) {
Some(true) => action = ws_toggle_action(row),
_ => {
if let Some(parent) = ws_parent_of(&rows, cur) {
new_cursor = parent;
}
}
}
}
if enter {
action = ws_enter_action(row);
}
if f2 {
action = ws_rename_action(row);
}
if del {
action = ws_delete_action(row);
}
if let Some(action) = action {
apply_ws_action(app, ci, action);
}
let col = &mut app.session.collections[ci];
let last = col.list_row_count().saturating_sub(1);
col.list_cursor = new_cursor.min(last);
app.reveal_selected = true;
}
fn ws_expansion(row: &WsRow) -> Option<bool> {
match row {
WsRow::Folder { expanded, .. } | WsRow::RequestFolder { expanded, .. } => Some(*expanded),
WsRow::Collection { open, .. } => Some(*open),
_ => None,
}
}
fn ws_toggle_action(row: &WsRow) -> Option<WsAction> {
match row {
WsRow::Folder { path, .. } | WsRow::RequestFolder { path, .. } => {
Some(WsAction::ToggleFolder(path.clone()))
}
WsRow::Collection { path, open, .. } => Some(WsAction::ToggleCollection {
path: path.clone(),
open: *open,
}),
_ => None,
}
}
fn ws_parent_of(rows: &[WsRow], cur: usize) -> Option<usize> {
let depth = rows[cur].depth();
rows[..cur].iter().rposition(|r| r.depth() < depth)
}
fn ws_enter_action(row: &WsRow) -> Option<WsAction> {
match row {
WsRow::Folder { .. } | WsRow::RequestFolder { .. } | WsRow::Collection { .. } => {
ws_toggle_action(row)
}
WsRow::Request {
collection,
idx,
loaded,
..
} => Some(WsAction::RunRequest {
collection: collection.clone(),
idx: *idx,
loaded: *loaded,
}),
WsRow::Report { path, .. } => Some(WsAction::OpenReport(path.clone())),
WsRow::Environment { path, .. } => Some(WsAction::OpenEnv {
path: path.clone(),
reveal: false,
}),
}
}
fn ws_rename_action(row: &WsRow) -> Option<WsAction> {
match row {
WsRow::Folder { path, .. }
| WsRow::Collection { path, .. }
| WsRow::Report { path, .. }
| WsRow::Environment { path, .. } => Some(WsAction::RenameItem { path: path.clone() }),
WsRow::Request {
collection,
idx,
loaded,
..
} => Some(WsAction::RequestAction {
collection: collection.clone(),
idx: *idx,
loaded: *loaded,
kind: ReqEdit::Rename,
}),
WsRow::RequestFolder { .. } => None,
}
}
fn ws_delete_action(row: &WsRow) -> Option<WsAction> {
match row {
WsRow::Folder { path, .. } => Some(WsAction::DeleteItem {
path: path.clone(),
is_dir: true,
}),
WsRow::Collection { path, .. }
| WsRow::Report { path, .. }
| WsRow::Environment { path, .. } => Some(WsAction::DeleteItem {
path: path.clone(),
is_dir: false,
}),
WsRow::Request {
collection,
idx,
loaded,
..
} => Some(WsAction::RequestAction {
collection: collection.clone(),
idx: *idx,
loaded: *loaded,
kind: ReqEdit::Delete,
}),
WsRow::RequestFolder { .. } => None,
}
}
fn workspace_ui(app: &mut GuiApp, ui: &mut egui::Ui, ci: usize) {
let theme = app.theme;
let (lbl_run_all, tip_run_all, lbl_filter, tip_filter, lbl_empty) = {
let s = &app.strings;
(
s.gui_run_all,
s.gui_run_all_tooltip,
s.gui_workspace_filter,
s.gui_workspace_filter_tooltip,
s.gui_no_requests_tree,
)
};
let (lbl_new, tip_new, lbl_in_folder, lbl_in_root, lbl_set_active_env) = {
let s = &app.strings;
(
s.gui_ws_new,
s.gui_ws_new_tooltip,
s.gui_ws_new_in_folder,
s.gui_ws_new_in_root,
s.gui_ws_set_active_env,
)
};
let (lbl_revert_req, lbl_revert_file) = (
app.strings.gui_ws_revert_request,
app.strings.gui_ws_revert_file,
);
let ws_rd = (app.strings.gui_ws_rename, app.strings.gui_ws_delete);
let (lbl_run, lbl_rename, lbl_duplicate, lbl_delete) = (
app.strings.gui_run,
app.strings.gui_rename_ellipsis,
app.strings.gui_duplicate,
app.strings.gui_delete,
);
let s_new = new_item_labels(&app.strings);
let name = app.session.collections[ci].name.clone();
let filter_on = app.session.collections[ci].workspace_filter_hurl_json;
let ws_root = app.session.collections[ci].workspace_root.clone();
let mut header_new: Option<WsAction> = None;
super::widgets::panel_header(ui, &theme, name, |ui| {
let run_all = format!("{} {}", super::icons::PLAY, lbl_run_all);
if ui.button(run_all).on_hover_text(tip_run_all).clicked() {
app.request_run_all(ci);
}
let ftxt =
RichText::new(lbl_filter).color(if filter_on { theme.accent } else { theme.dim });
if ui.button(ftxt).on_hover_text(tip_filter).clicked() {
let col = &mut app.session.collections[ci];
col.workspace_filter_hurl_json = !col.workspace_filter_hurl_json;
app.session.save();
}
if let Some(root) = ws_root.clone() {
let label = format!("{} {}", super::icons::PLUS, lbl_new);
let menu = ui.menu_button(label, |ui| {
if let Some(kind) = new_item_menu(ui, s_new) {
header_new = Some(WsAction::NewItem { dir: root, kind });
ui.close();
}
});
menu.response.on_hover_text(tip_new);
}
});
ui.separator();
let rows = app.session.collections[ci].ws_rows();
if rows.is_empty() {
ui.add_space(8.0);
ui.colored_label(theme.dim, lbl_empty);
if let Some(action) = header_new {
apply_ws_action(app, ci, action);
}
return;
}
let selected_entry = app.session.collections[ci].selected_entry;
let loaded_path = app.session.collections[ci].path.clone();
let lbl_edited = app.strings.gui_edited_request;
let lbl_edited_col = app.strings.gui_edited_collection;
let report_path = app
.report_editor
.as_ref()
.and_then(|e| e.path().map(std::path::Path::to_path_buf));
let mut actions: Vec<WsAction> = Vec::new();
let cursor = app.session.collections[ci].list_cursor.min(rows.len() - 1);
let show_cursor = app.focus == super::Focus::List;
let reveal = app.reveal_selected;
let mut click_cursor: Option<usize> = None;
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
super::widgets::tree_rhythm(ui);
let bg_rect = ui.clip_rect();
let bg = ui.interact(
bg_rect,
ui.id().with("ws_background"),
egui::Sense::click_and_drag(),
);
if let Some(root) = &ws_root {
ws_row_menu(&bg, root.clone(), lbl_in_root, s_new, &mut actions);
}
let mut folder_rects: Vec<egui::Rect> = Vec::new();
for (row_idx, row) in rows.iter().enumerate() {
let resp: egui::Response = match row {
WsRow::Folder {
path,
name,
depth,
expanded,
} => {
let chev = if *expanded {
super::icons::CARET_DOWN
} else {
super::icons::CARET_RIGHT
};
let text = RichText::new(format!("{chev} {} {name}", super::icons::FOLDER))
.color(theme.text);
let resp = ws_row(ui, *depth, false, text);
if resp.clicked() {
actions.push(WsAction::ToggleFolder(path.clone()));
}
ws_row_menu_with(
&resp,
path.clone(),
lbl_in_folder,
s_new,
&mut actions,
|ui| {
let chosen = ws_rename_delete_entries(ui, path, true, ws_rd);
ui.separator();
chosen
},
);
ws_drag_and_drop(ui, &resp, &theme, path, true, &mut actions);
folder_rects.push(resp.rect);
resp
}
WsRow::Collection {
path,
name,
depth,
open,
} => {
let chev = if *open {
super::icons::CARET_DOWN
} else {
super::icons::CARET_RIGHT
};
let is_loaded = loaded_path.as_deref() == Some(path.as_path());
let color = if is_loaded { theme.accent } else { theme.text };
let pencil = if app.session.collections[ci].workspace_file_edited(path) {
format!(" {}", super::icons::EDITED)
} else {
String::new()
};
let text =
RichText::new(format!("{chev} {} {name}{pencil}", super::icons::FILE))
.color(color);
let resp = ws_row(ui, *depth, is_loaded, text);
let resp = if pencil.is_empty() {
resp
} else {
resp.on_hover_text(lbl_edited_col)
};
if resp.clicked() {
actions.push(WsAction::ToggleCollection {
path: path.clone(),
open: *open,
});
}
let edited_file = app.session.collections[ci].workspace_file_edited(path);
ws_row_menu_with(
&resp,
sibling_dir(path),
lbl_in_folder,
s_new,
&mut actions,
|ui| {
let mut chosen = ws_rename_delete_entries(ui, path, false, ws_rd);
if edited_file && ui.button(lbl_revert_file).clicked() {
chosen = chosen.or(Some(WsAction::RevertFile(path.clone())));
}
ui.separator();
chosen
},
);
ws_drag_and_drop(ui, &resp, &theme, path, false, &mut actions);
resp
}
WsRow::RequestFolder {
path,
name,
depth,
expanded,
..
} => {
let chev = if *expanded {
super::icons::CARET_DOWN
} else {
super::icons::CARET_RIGHT
};
let text = RichText::new(format!("{chev} {} {name}", super::icons::FOLDER))
.color(theme.text);
let resp = ui
.push_id(("ws_req_folder", path), |ui| {
ws_row(ui, *depth, false, text)
})
.inner;
if resp.clicked() {
actions.push(WsAction::ToggleFolder(path.clone()));
}
resp
}
WsRow::Request {
collection,
idx,
name,
method,
depth,
loaded,
} => {
let is_sel = *loaded
&& loaded_path.as_deref() == Some(collection.as_path())
&& *idx == selected_entry;
let marker = run_marker(
app.session.collections[ci].workspace_run_status(collection, *idx),
);
let edited =
app.session.collections[ci].workspace_request_edited(collection, *idx);
let resp = clickable_row(ui, ("ws_req", collection, idx), |ui| {
ui.horizontal(|ui| {
ui.add_space(*depth as f32 * WS_INDENT);
if !method.is_empty() {
super::widgets::method_badge(ui, &theme, method);
}
let text = if is_sel {
RichText::new(name).strong().color(theme.text)
} else {
RichText::new(name).color(theme.text)
};
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.add_space(SCROLLBAR_GUTTER);
let (mk, ok) = marker;
if !mk.is_empty() {
let mc = if ok { theme.ok } else { theme.err };
ui.colored_label(mc, mk);
}
if edited {
ui.colored_label(theme.pending, super::icons::EDITED)
.on_hover_text(lbl_edited);
}
ui.with_layout(
egui::Layout::left_to_right(egui::Align::Center),
|ui| super::widgets::selectable_row(ui, is_sel, text),
)
.inner
},
)
.inner
})
.inner
});
if resp.clicked() {
actions.push(WsAction::SelectRequest {
collection: collection.clone(),
idx: *idx,
loaded: *loaded,
});
}
if resp.double_clicked() {
actions.push(WsAction::RunRequest {
collection: collection.clone(),
idx: *idx,
loaded: *loaded,
});
}
let is_loaded_here =
*loaded && loaded_path.as_deref() == Some(collection.as_path());
let revertable = is_loaded_here
&& app.session.collections[ci]
.entries
.get(*idx)
.is_some_and(|e| e.modified);
ws_row_menu_with(
&resp,
sibling_dir(collection),
lbl_in_folder,
s_new,
&mut actions,
|ui| {
if ui.button(lbl_run).clicked() {
return Some(WsAction::RunRequest {
collection: collection.clone(),
idx: *idx,
loaded: *loaded,
});
}
if ui.button(lbl_rename).clicked() {
return Some(WsAction::RequestAction {
collection: collection.clone(),
idx: *idx,
loaded: *loaded,
kind: ReqEdit::Rename,
});
}
if ui.button(lbl_duplicate).clicked() {
return Some(WsAction::RequestAction {
collection: collection.clone(),
idx: *idx,
loaded: *loaded,
kind: ReqEdit::Duplicate,
});
}
if ui.button(lbl_delete).clicked() {
return Some(WsAction::RequestAction {
collection: collection.clone(),
idx: *idx,
loaded: *loaded,
kind: ReqEdit::Delete,
});
}
if revertable && ui.button(lbl_revert_req).clicked() {
return Some(WsAction::RevertRequest {
collection: collection.clone(),
idx: *idx,
});
}
ui.separator();
None
},
);
if is_loaded_here {
if resp.drag_started() {
egui::DragAndDrop::set_payload(
ui.ctx(),
WsReqDrag {
collection: collection.clone(),
idx: *idx,
},
);
}
if resp.dragged()
&& let Some(pos) = ui.ctx().pointer_interact_pos()
{
let layer = egui::LayerId::new(
egui::Order::Tooltip,
ui.id().with("ws_req_drag_label"),
);
ui.ctx().layer_painter(layer).text(
pos + egui::vec2(12.0, 4.0),
egui::Align2::LEFT_TOP,
name,
egui::TextStyle::Button.resolve(ui.style()),
theme.accent,
);
}
ws_request_drop_zone(ui, &resp, &theme, collection, *idx, &mut actions);
}
resp
}
WsRow::Report { path, name, depth } => {
let is_open = report_path.as_deref() == Some(path.as_path());
let color = if is_open { theme.accent } else { theme.pending };
let text =
RichText::new(format!("{} {name}", super::icons::REPORT)).color(color);
let resp = ws_row(ui, *depth, is_open, text);
if resp.clicked() {
actions.push(WsAction::OpenReport(path.clone()));
}
ws_row_menu_with(
&resp,
sibling_dir(path),
lbl_in_folder,
s_new,
&mut actions,
|ui| {
let chosen = ws_rename_delete_entries(ui, path, false, ws_rd);
ui.separator();
chosen
},
);
ws_drag_and_drop(ui, &resp, &theme, path, false, &mut actions);
resp
}
WsRow::Environment { path, name, depth } => {
let text = RichText::new(format!("{} {name}", super::icons::ENV))
.color(theme.subst);
let resp = ws_row(ui, *depth, false, text);
if resp.clicked() || resp.double_clicked() {
actions.push(WsAction::OpenEnv {
path: path.clone(),
reveal: resp.double_clicked(),
});
}
ws_row_menu_with(
&resp,
sibling_dir(path),
lbl_in_folder,
s_new,
&mut actions,
|ui| {
let mut chosen = ws_rename_delete_entries(ui, path, false, ws_rd);
if ui.button(lbl_set_active_env).clicked() {
chosen = chosen.or(Some(WsAction::ActivateEnv(path.clone())));
}
ui.separator();
chosen
},
);
ws_drag_and_drop(ui, &resp, &theme, path, false, &mut actions);
resp
}
};
if resp.clicked() {
click_cursor = Some(row_idx);
}
ws_cursor_decoration(
ui,
&resp,
show_cursor && row_idx == cursor,
reveal && row_idx == cursor,
&theme,
);
}
if let Some(root) = &ws_root {
ws_root_drop(ui, bg_rect, &folder_rects, &theme, root, &mut actions);
}
});
app.reveal_selected = false;
for action in header_new.into_iter().chain(actions) {
apply_ws_action(app, ci, action);
}
if let Some(idx) = click_cursor {
let col = &mut app.session.collections[ci];
let last = col.list_row_count().saturating_sub(1);
col.list_cursor = idx.min(last);
}
}
fn sibling_dir(path: &std::path::Path) -> PathBuf {
path.parent().unwrap_or(path).to_path_buf()
}
fn new_item_menu(
ui: &mut egui::Ui,
labels: NewItemLabels,
) -> Option<crate::workspace::NewItemKind> {
use crate::workspace::NewItemKind;
let (collection, report, env, folder) = labels;
for (label, kind) in [
(collection, NewItemKind::Collection),
(report, NewItemKind::Report),
(env, NewItemKind::Environment),
] {
if ui.button(label).clicked() {
return Some(kind);
}
}
ui.separator();
if ui.button(folder).clicked() {
return Some(NewItemKind::Folder);
}
None
}
type NewItemLabels = (&'static str, &'static str, &'static str, &'static str);
fn new_item_labels(s: &crate::i18n::Strings) -> NewItemLabels {
(
s.gui_ws_new_collection,
s.gui_ws_new_report,
s.gui_ws_new_environment,
s.gui_ws_new_folder,
)
}
fn new_workspace_item(
app: &mut GuiApp,
ci: usize,
dir: &std::path::Path,
kind: crate::workspace::NewItemKind,
) {
use crate::workspace::NewItemKind;
if app.session.collections[ci].workspace_root.is_none() {
return;
}
if kind == crate::workspace::NewItemKind::Folder {
app.dialog = Some(super::app::Dialog::Prompt {
kind: super::app::PromptKind::NewWorkspaceFolder {
ci,
dir: dir.to_path_buf(),
},
text: String::new(),
});
return;
}
let s = &app.strings;
let (title, default) = match kind {
NewItemKind::Collection => (s.gui_ws_new_collection_title, "collection.hurl"),
NewItemKind::Report => (s.gui_ws_new_report_title, "report.trail"),
NewItemKind::Environment => (s.gui_ws_new_environment_title, "environment.vars"),
NewItemKind::Folder => return,
};
let ext = kind.extension();
app.request_pick(
super::filepick::PickKind::Save {
default_name: default.to_string(),
filters: super::filepick::owned_filters(&[(ext, &[ext])]),
},
title,
Some(dir),
super::menu::PickAction::NewWorkspaceItem { ci, kind },
);
}
pub(super) fn apply_new_workspace_item(
app: &mut GuiApp,
ci: usize,
kind: crate::workspace::NewItemKind,
picked: Option<std::path::PathBuf>,
) {
use crate::workspace::{NewItemError, NewItemKind};
let Some(chosen) = picked else {
return; };
let Some(root) = app
.session
.collections
.get(ci)
.and_then(|c| c.workspace_root.clone())
else {
return;
};
let parent = chosen.parent().unwrap_or(&root).to_path_buf();
let name = chosen
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
match crate::workspace::create_item(&root, &parent, &name, kind) {
Ok(path) => {
reveal_in_tree(app, ci, &path, &root);
app.session.status = Some(crate::i18n::Status::WsItemCreated(
crate::workspace::display_name(&root, &path),
));
let follow = match kind {
NewItemKind::Collection => WsAction::ToggleCollection {
path: path.clone(),
open: false,
},
NewItemKind::Report => WsAction::OpenReport(path.clone()),
NewItemKind::Environment => WsAction::OpenEnv {
path: path.clone(),
reveal: true,
},
NewItemKind::Folder => return,
};
let created = app.session.status.clone();
apply_ws_action(app, ci, follow);
if app.session.status.is_none() {
app.session.status = created;
}
}
Err(NewItemError::EmptyName) => {}
Err(NewItemError::Escapes(what)) => {
app.session.status = Some(crate::i18n::Status::WsItemEscaped(what));
}
Err(NewItemError::Exists(what)) => {
app.session.status = Some(crate::i18n::Status::WsItemExists(what));
}
Err(NewItemError::Io(what)) => {
app.session.status = Some(crate::i18n::Status::Error(what));
}
}
}
pub(super) fn new_workspace_folder(app: &mut GuiApp, ci: usize, dir: &std::path::Path, name: &str) {
use crate::workspace::{NewItemError, NewItemKind};
let Some(root) = app.session.collections[ci].workspace_root.clone() else {
return;
};
match crate::workspace::create_item(&root, dir, name, NewItemKind::Folder) {
Ok(path) => {
reveal_in_tree(app, ci, &path, &root);
app.session.collections[ci]
.workspace_expanded
.insert(path.clone());
app.session.status = Some(crate::i18n::Status::WsItemCreated(
crate::workspace::display_name(&root, &path),
));
app.session.save();
}
Err(NewItemError::EmptyName) => {}
Err(NewItemError::Escapes(what)) => {
app.session.status = Some(crate::i18n::Status::WsItemEscaped(what));
}
Err(NewItemError::Exists(what)) => {
app.session.status = Some(crate::i18n::Status::WsItemExists(what));
}
Err(NewItemError::Io(what)) => {
app.session.status = Some(crate::i18n::Status::Error(what));
}
}
}
fn move_workspace_item(
app: &mut GuiApp,
ci: usize,
src: &std::path::Path,
dest_dir: &std::path::Path,
) {
use crate::workspace::{MoveError, move_item};
let Some(root) = app.session.collections[ci].workspace_root.clone() else {
return;
};
let dest = match move_item(&root, src, dest_dir) {
Ok(dest) => dest,
Err(MoveError::Exists(what)) => {
app.session.status = Some(crate::i18n::Status::WsItemMoveExists(what));
return;
}
Err(MoveError::IntoItself) => {
app.session.status = Some(crate::i18n::Status::WsItemMoveIntoItself);
return;
}
Err(MoveError::Escapes(what)) => {
app.session.status = Some(crate::i18n::Status::WsItemEscaped(what));
return;
}
Err(MoveError::Io(what)) => {
app.session.status = Some(crate::i18n::Status::Error(what));
return;
}
};
if dest == src {
return;
}
repoint_workspace_holdings(app, src, &dest);
reveal_in_tree(app, ci, &dest, &root);
app.session.status = Some(crate::i18n::Status::WsItemMoved(
crate::workspace::display_name(&root, &dest),
));
}
pub(super) fn rename_workspace_item(
app: &mut GuiApp,
ci: usize,
src: &std::path::Path,
new_name: &str,
) {
use crate::workspace::{RenameError, rename_item};
let Some(root) = app.session.collections[ci].workspace_root.clone() else {
return;
};
let dest = match rename_item(&root, src, new_name) {
Ok(dest) => dest,
Err(RenameError::EmptyName) => return,
Err(RenameError::Exists(what)) => {
app.session.status = Some(crate::i18n::Status::WsItemRenameExists(what));
return;
}
Err(RenameError::Escapes(what)) => {
app.session.status = Some(crate::i18n::Status::WsItemEscaped(what));
return;
}
Err(RenameError::Io(what)) => {
app.session.status = Some(crate::i18n::Status::Error(what));
return;
}
};
if dest == src {
return;
}
repoint_workspace_holdings(app, src, &dest);
reveal_in_tree(app, ci, &dest, &root);
app.session.status = Some(crate::i18n::Status::WsItemRenamed(
crate::workspace::display_name(&root, &dest),
));
}
pub(super) fn delete_workspace_item(app: &mut GuiApp, ci: usize, path: &std::path::Path) {
use crate::workspace::{DeleteError, delete_item};
let Some(root) = app.session.collections[ci].workspace_root.clone() else {
return;
};
match delete_item(&root, path) {
Ok(()) => {}
Err(DeleteError::IsRoot) => {
app.session.status = Some(crate::i18n::Status::WsItemDeleteRoot);
return;
}
Err(DeleteError::Escapes(what)) => {
app.session.status = Some(crate::i18n::Status::WsItemEscaped(what));
return;
}
Err(DeleteError::Io(what)) => {
app.session.status = Some(crate::i18n::Status::Error(what));
return;
}
}
let name = crate::workspace::display_name(&root, path);
for col in &mut app.session.collections {
col.prune_workspace_paths(path);
}
if let Some(ed) = app.report_editor.as_ref()
&& ed.path().is_some_and(|p| p.starts_with(path))
{
app.close_report_editor();
}
app.session.status = Some(crate::i18n::Status::WsItemDeleted(name));
app.session.save();
}
fn repoint_workspace_holdings(app: &mut GuiApp, src: &std::path::Path, dest: &std::path::Path) {
for col in &mut app.session.collections {
col.repoint_workspace_paths(src, dest);
}
if let Some(ed) = app.report_editor.as_mut()
&& let Some(p) = ed
.path()
.and_then(|p| crate::workspace::repoint(p, src, dest))
{
ed.report.path = Some(p);
}
}
fn reveal_in_tree(app: &mut GuiApp, ci: usize, path: &std::path::Path, root: &std::path::Path) {
let col = &mut app.session.collections[ci];
let mut cur = path.parent();
while let Some(dir) = cur {
if !dir.starts_with(root) {
break;
}
col.workspace_expanded.insert(dir.to_path_buf());
if dir == root {
break;
}
cur = dir.parent();
}
col.workspace_selected = Some(path.to_path_buf());
app.session.save();
}
fn ws_root_drop(
ui: &mut egui::Ui,
area: egui::Rect,
folder_rects: &[egui::Rect],
theme: &super::theme::GuiTheme,
root: &std::path::Path,
actions: &mut Vec<WsAction>,
) {
let Some(dragged) = egui::DragAndDrop::payload::<WsDrag>(ui.ctx()) else {
return;
};
let Some(pos) = ui.ctx().pointer_interact_pos() else {
return;
};
if !area.contains(pos) || folder_rects.iter().any(|r| r.contains(pos)) {
return;
}
if dragged.0.parent() == Some(root) {
return;
}
ui.painter().rect_stroke(
area.shrink(1.0),
6.0,
egui::Stroke::new(1.0, theme.accent),
egui::StrokeKind::Inside,
);
if ui.input(|i| i.pointer.any_released()) {
actions.push(WsAction::MoveItem {
src: dragged.0.clone(),
dest_dir: root.to_path_buf(),
});
egui::DragAndDrop::clear_payload(ui.ctx());
}
}
fn ws_row_menu(
resp: &egui::Response,
dir: PathBuf,
header: &'static str,
labels: NewItemLabels,
actions: &mut Vec<WsAction>,
) {
ws_row_menu_with(resp, dir, header, labels, actions, |_| None);
}
fn ws_row_menu_with(
resp: &egui::Response,
dir: PathBuf,
header: &'static str,
labels: NewItemLabels,
actions: &mut Vec<WsAction>,
extra: impl FnOnce(&mut egui::Ui) -> Option<WsAction>,
) {
resp.context_menu(|ui| {
let mut extra = Some(extra);
if let Some(action) = extra.take().and_then(|f| f(ui)) {
actions.push(action);
ui.close();
}
ui.label(header);
ui.separator();
if let Some(kind) = new_item_menu(ui, labels) {
actions.push(WsAction::NewItem { dir, kind });
ui.close();
}
});
}
fn ws_rename_delete_entries(
ui: &mut egui::Ui,
path: &std::path::Path,
is_dir: bool,
labels: (&'static str, &'static str),
) -> Option<WsAction> {
let (lbl_rename, lbl_delete) = labels;
if ui.button(lbl_rename).clicked() {
return Some(WsAction::RenameItem {
path: path.to_path_buf(),
});
}
if ui.button(lbl_delete).clicked() {
return Some(WsAction::DeleteItem {
path: path.to_path_buf(),
is_dir,
});
}
None
}
fn resolve_ws_request(
app: &mut GuiApp,
ci: usize,
collection: &std::path::Path,
idx: usize,
loaded: bool,
) -> Option<usize> {
let already = loaded && app.session.collections[ci].path.as_deref() == Some(collection);
if !already
&& !(app
.session
.load_workspace_file(ci, collection.to_path_buf())
&& app.session.collections[ci].path.as_deref() == Some(collection))
{
return None;
}
let n = app.session.collections[ci].entries.len();
let idx = idx.min(n.saturating_sub(1));
app.session.collections[ci].entries.get(idx).map(|_| idx)
}
fn apply_ws_action(app: &mut GuiApp, ci: usize, action: WsAction) {
match &action {
WsAction::ToggleCollection { path, .. }
| WsAction::SelectRequest {
collection: path, ..
}
| WsAction::RunRequest {
collection: path, ..
}
| WsAction::RequestAction {
collection: path, ..
}
| WsAction::ReorderRequest {
collection: path, ..
}
| WsAction::OpenReport(path)
| WsAction::OpenEnv { path, .. }
| WsAction::ActivateEnv(path) => {
app.session.collections[ci].workspace_selected = Some(path.clone());
}
WsAction::ToggleFolder(_)
| WsAction::NewItem { .. }
| WsAction::MoveItem { .. }
| WsAction::RenameItem { .. }
| WsAction::DeleteItem { .. }
| WsAction::RevertRequest { .. }
| WsAction::RevertFile(_) => {}
}
match action {
WsAction::RevertRequest { collection, idx } => {
ask_revert_request(app, ci, collection, idx);
}
WsAction::RevertFile(path) => {
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
app.dialog = Some(super::app::Dialog::RevertToSaved {
ci,
path,
entry: None,
name,
});
}
WsAction::ToggleFolder(p) => {
let col = &mut app.session.collections[ci];
if col.workspace_expanded.contains(&p) {
col.workspace_expanded.remove(&p);
} else {
col.workspace_expanded.insert(p);
}
app.session.save();
}
WsAction::ToggleCollection { path, open } => {
if open {
app.session.collections[ci].workspace_expanded.remove(&path);
} else if app.session.collections[ci].path.as_deref() == Some(path.as_path()) {
app.session.collections[ci].workspace_expanded.insert(path);
app.session.collections[ci].sync_ws_cursor();
} else {
app.session.load_workspace_file(ci, path);
}
app.close_report_editor();
app.focus = super::Focus::List;
app.session.save();
}
WsAction::SelectRequest {
collection,
idx,
loaded,
} => {
if loaded {
let col = &mut app.session.collections[ci];
col.selected_entry = idx;
col.sync_folder_to_selected();
col.invalidate_request_json();
} else if app.session.load_workspace_file(ci, collection.clone())
&& app.session.collections[ci].path.as_deref() == Some(collection.as_path())
{
let col = &mut app.session.collections[ci];
let n = col.entries.len();
col.selected_entry = idx.min(n.saturating_sub(1));
col.sync_folder_to_selected();
col.invalidate_request_json();
}
app.close_report_editor();
app.focus = super::Focus::List;
app.session.save();
}
WsAction::RunRequest {
collection,
idx,
loaded,
} => {
if loaded {
app.session.collections[ci].selected_entry = idx;
} else if app.session.load_workspace_file(ci, collection.clone())
&& app.session.collections[ci].path.as_deref() == Some(collection.as_path())
{
let n = app.session.collections[ci].entries.len();
app.session.collections[ci].selected_entry = idx.min(n.saturating_sub(1));
}
app.close_report_editor();
app.session.save();
app.run_active();
}
WsAction::RequestAction {
collection,
idx,
loaded,
kind,
} => {
if let Some(idx) = resolve_ws_request(app, ci, &collection, idx, loaded) {
let actions = match kind {
ReqEdit::Rename => Actions {
rename: Some(idx),
..Default::default()
},
ReqEdit::Duplicate => Actions {
duplicate: Some(idx),
..Default::default()
},
ReqEdit::Delete => Actions {
delete: Some(idx),
..Default::default()
},
};
apply_actions(app, ci, actions);
}
}
WsAction::ReorderRequest {
collection,
from,
before,
} => {
if app.session.collections[ci].path.as_deref() == Some(collection.as_path()) {
apply_actions(
app,
ci,
Actions {
reorder: Some((from, before)),
..Default::default()
},
);
}
}
WsAction::OpenReport(path) => match crate::report::Report::load_local(&path) {
Ok(report) => {
app.open_report_editor(super::report_editor::ReportOrigin::Workspace, report);
app.focus = super::Focus::Main;
app.session.save();
}
Err(e) => {
app.session.status = Some(crate::i18n::Status::Error(e));
}
},
WsAction::NewItem { dir, kind } => new_workspace_item(app, ci, &dir, kind),
WsAction::MoveItem { src, dest_dir } => move_workspace_item(app, ci, &src, &dest_dir),
WsAction::RenameItem { path } => {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
app.dialog = Some(super::app::Dialog::Rename {
target: super::app::RenameTarget::WorkspaceItem { ci, path },
text: name,
});
}
WsAction::DeleteItem { path, is_dir } => {
let name = app.session.collections[ci]
.workspace_root
.as_deref()
.map(|root| crate::workspace::display_name(root, &path))
.unwrap_or_else(|| {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
});
let file_count = if is_dir {
crate::workspace::descendant_file_count(&path)
} else {
1
};
let unsaved = app.session.collections[ci].workspace_unsaved_under(&path);
app.dialog = Some(super::app::Dialog::DeleteWorkspaceItem {
ci,
path,
is_dir,
name,
file_count,
unsaved,
});
}
WsAction::OpenEnv { path, reveal } => {
let id = app.session.open_workspace_environment(&path);
if reveal {
app.reveal_env = id;
}
app.close_report_editor();
app.session.save();
}
WsAction::ActivateEnv(path) => {
let existing = app
.session
.global_envs
.iter()
.find(|e| e.path.as_deref() == Some(path.as_path()))
.map(|e| e.id);
let id = match existing {
Some(id) => Some(id),
None => app.session.open_workspace_environment(&path),
};
if id.is_some() {
if app.session.active_env_id != id {
app.session.set_active_env(id);
}
app.reveal_env = id;
}
app.session.save();
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
#[test]
fn activating_a_workspace_environment_from_the_tree_loads_it_and_makes_it_active() {
let dir = ws_tmp("activate");
let env = dir.join("api/v1/dev.vars");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
session.active_tab = ci;
let mut app = GuiApp::for_test(session);
apply_ws_action(&mut app, ci, WsAction::ActivateEnv(env.clone()));
assert_eq!(app.session.global_envs.len(), 1, "the file was loaded");
let id = app.session.global_envs[0].id;
assert_eq!(app.session.active_env_id, Some(id));
assert_eq!(app.reveal_env, Some(id), "and it is shown in the panel");
apply_ws_action(&mut app, ci, WsAction::ActivateEnv(env));
assert_eq!(app.session.global_envs.len(), 1);
assert_eq!(app.session.active_env_id, Some(id));
let _ = std::fs::remove_dir_all(&dir);
}
fn id_clashes(shapes: &[egui::epaint::ClippedShape]) -> Vec<String> {
fn walk(shape: &egui::epaint::Shape, out: &mut Vec<String>) {
match shape {
egui::epaint::Shape::Text(t) => {
let text = t.galley.text();
if text.contains('\u{1f525}') {
out.push(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 draw_app(ctx: &egui::Context, app: &mut GuiApp, pointer: egui::Pos2) -> Vec<String> {
redirect_saved_state();
let mut fonts = egui::FontDefinitions::default();
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Light);
ctx.set_fonts(fonts);
let mut input = egui::RawInput::default();
input.screen_rect = Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(1200.0, 800.0),
));
input.events.push(egui::Event::PointerMoved(pointer));
let out = ctx.run_ui(input, |ui| app.draw(ui));
id_clashes(&out.shapes)
}
#[test]
fn the_id_clash_detector_really_sees_a_clash() {
let ctx = egui::Context::default();
let mut input = egui::RawInput::default();
input.screen_rect = Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(400.0, 600.0),
));
let out = ctx.run_ui(input, |ui| {
let r1 = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(10.0, 10.0));
let r2 = egui::Rect::from_min_size(egui::pos2(50.0, 50.0), egui::vec2(10.0, 10.0));
ui.interact(r1, egui::Id::new("dup"), egui::Sense::click());
ui.interact(r2, egui::Id::new("dup"), egui::Sense::click());
});
let c = id_clashes(&out.shapes);
assert!(!c.is_empty(), "detector should see the clash");
}
pub(crate) fn redirect_saved_state() {
static ONCE: std::sync::OnceLock<()> = std::sync::OnceLock::new();
ONCE.get_or_init(|| {
let dir =
std::env::temp_dir().join(format!("paperboy_gui_state_{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
unsafe { std::env::set_var("PAPERBOY_STATE_DIR", &dir) };
});
}
pub(crate) fn ws_tmp(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"paperboy_gui_reqs_{tag}_{}_{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
let api = dir.join("api/v1");
std::fs::create_dir_all(&api).unwrap();
std::fs::write(dir.join("health.hurl"), "GET https://example.com/health\n").unwrap();
std::fs::write(
api.join("one.hurl"),
"GET https://example.com/a\nHTTP 200\n\nGET https://example.com/a2\n",
)
.unwrap();
std::fs::write(
api.join("two.hurl"),
"GET https://example.com/b\nHTTP 200\n\nGET https://example.com/b2\n",
)
.unwrap();
std::fs::write(api.join("dev.vars"), "BASE_URL=https://example.com\n").unwrap();
std::fs::write(api.join("run.trail"), "{\"nodes\":[]}\n").unwrap();
dir
}
fn expand_all(col: &mut crate::collection::Collection, dir: &std::path::Path) {
for p in [
dir.to_path_buf(),
dir.join("api"),
dir.join("api/v1"),
dir.join("api/v1/one.hurl"),
dir.join("api/v1/two.hurl"),
dir.join("health.hurl"),
] {
col.workspace_expanded.insert(p);
}
}
fn draw_panel(app: &mut GuiApp) -> Vec<String> {
let ctx = egui::Context::default();
let mut input = egui::RawInput::default();
input.screen_rect = Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(400.0, 600.0),
));
let out = ctx.run_ui(input, |panel| crate::gui::requests::ui(app, panel));
id_clashes(&out.shapes)
}
fn texts(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) => {
for s in v {
walk(s, out);
}
}
_ => {}
}
}
let mut out = Vec::new();
for cs in shapes {
walk(&cs.shape, &mut out);
}
out
}
fn text_color(shapes: &[egui::epaint::ClippedShape], needle: &str) -> egui::Color32 {
fn walk(shape: &egui::epaint::Shape, needle: &str, out: &mut Option<egui::Color32>) {
match shape {
egui::epaint::Shape::Text(t) if t.galley.text().contains(needle) => {
*out = t
.override_text_color
.or_else(|| t.galley.job.sections.first().map(|s| s.format.color));
}
egui::epaint::Shape::Vec(v) => {
for s in v {
walk(s, needle, out);
}
}
_ => {}
}
}
let mut out = None;
for cs in shapes {
walk(&cs.shape, needle, &mut out);
}
out.expect("the name is drawn")
}
#[test]
fn an_unselected_request_is_not_painted_in_the_disabled_colour() {
redirect_saved_state();
let dir = ws_tmp("dimrow");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
expand_all(&mut session.collections[ci], &dir);
assert!(session.load_workspace_file(ci, dir.join("api/v1/one.hurl")));
session.collections[ci].selected_entry = 0;
session.active_tab = ci;
let mut app = GuiApp::for_test(session);
let dim = app.theme.dim;
let text = app.theme.text;
let ctx = egui::Context::default();
let out = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(400.0, 600.0),
)),
..Default::default()
},
|ui| crate::gui::requests::ui(&mut app, ui),
);
let got = text_color(&out.shapes, "example.com/a2");
assert_ne!(got, dim, "an unselected request is not disabled");
assert_eq!(got, text, "it is ordinary content");
}
#[test]
fn tree_rows_sit_closer_together_than_two_controls_would() {
redirect_saved_state();
let dir = ws_tmp("rowpitch");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
expand_all(&mut session.collections[ci], &dir);
assert!(session.load_workspace_file(ci, dir.join("api/v1/one.hurl")));
session.active_tab = ci;
let mut app = GuiApp::for_test(session);
let ctx = egui::Context::default();
crate::gui::theme::GuiTheme::from_spec(&crate::theme::default_preset()).apply(&ctx);
let out = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(400.0, 600.0),
)),
..Default::default()
},
|ui| crate::gui::requests::ui(&mut app, ui),
);
let drawn = texts(&out.shapes);
let row = |needle: &str| {
drawn
.iter()
.find(|(t, _)| t == needle)
.unwrap_or_else(|| panic!("{needle} is drawn"))
.1
};
let first = row("https://example.com/a");
let second = row("https://example.com/a2");
let pitch = second.top() - first.top();
assert!(pitch > 0.0, "the rows are drawn in order: {pitch}");
assert!(
pitch <= 21.0,
"tree rows are spaced like separate controls ({pitch}px apart)"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn clicking_anywhere_on_a_request_row_selects_it() {
redirect_saved_state();
let dir = ws_tmp("rowclick");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
expand_all(&mut session.collections[ci], &dir);
assert!(session.load_workspace_file(ci, dir.join("api/v1/one.hurl")));
session.collections[ci].selected_entry = 0;
session.active_tab = ci;
let mut app = GuiApp::for_test(session);
let ctx = egui::Context::default();
let screen = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 600.0));
let frame = |events: Vec<egui::Event>| egui::RawInput {
screen_rect: Some(screen),
events,
..Default::default()
};
let out = ctx.run_ui(frame(Vec::new()), |ui| {
crate::gui::requests::ui(&mut app, ui)
});
let row = texts(&out.shapes)
.into_iter()
.find(|(t, _)| t.contains("example.com/a2"))
.expect("the second request's row is drawn")
.1;
let badge = egui::pos2(row.left() - 20.0, row.center().y);
assert!(badge.x > 0.0, "the badge is inside the panel");
let mut events = vec![egui::Event::PointerMoved(badge)];
for pressed in [true, false] {
events.push(egui::Event::PointerButton {
pos: badge,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
});
}
let _ = ctx.run_ui(frame(events), |ui| crate::gui::requests::ui(&mut app, ui));
assert_eq!(
app.session.collections[ci].selected_entry, 1,
"clicking the method badge selects the row it belongs to"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn switching_the_loaded_collection_never_provokes_an_egui_id_clash() {
let dir = ws_tmp("clash");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
expand_all(&mut session.collections[ci], &dir);
assert!(session.load_workspace_file(ci, dir.join("api/v1/one.hurl")));
session.collections[ci].entries[0].modified = true;
assert!(
session.collections[ci]
.ws_rows()
.iter()
.any(|r| matches!(r, crate::collection::WsRow::Request { .. })),
"fixture must list request rows, or there is no pencil to clash over"
);
assert!(
session.collections[ci].workspace_request_edited(&dir.join("api/v1/one.hurl"), 0),
"fixture must have an edited request, or no pencil is drawn"
);
let mut app = GuiApp::for_test(session);
for _ in 0..2 {
let clashes = draw_panel(&mut app);
assert!(
clashes.is_empty(),
"drawing the workspace tree with an edited request clashed: {clashes:?}"
);
}
assert!(
app.session
.load_workspace_file(ci, dir.join("api/v1/two.hurl"))
);
assert!(
app.session.collections[ci].workspace_request_edited(&dir.join("api/v1/one.hurl"), 0),
"the parked collection must keep its pencil after the switch"
);
assert!(
app.session.collections[ci]
.ws_rows()
.iter()
.any(|r| matches!(
r,
crate::collection::WsRow::Request { collection, loaded: false, .. }
if collection == &dir.join("api/v1/one.hurl")
)),
"the parked collection's request rows must still be drawn"
);
for _ in 0..2 {
let clashes = draw_panel(&mut app);
assert!(
clashes.is_empty(),
"switching the loaded collection clashed: {clashes:?}"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn switching_the_active_tab_never_provokes_an_egui_id_clash() {
let dir = ws_tmp("tabclash");
let mut session = crate::session::Session::default();
session.collections.clear();
session.add_collection("scratch");
for (i, url) in ["https://example.com/x", "https://example.com/y"]
.into_iter()
.enumerate()
{
let mut e = crate::hurl::HurlEntry::default();
e.title = format!("req {i}");
e.method = "GET".into();
e.url = url.into();
session.collections[0].entries.push(e);
}
session.collections[0].entries[1].modified = true;
session.collections[0].entries[0].last_run = RunStatus::Passed;
let ci = session.open_workspace(dir.clone());
expand_all(&mut session.collections[ci], &dir);
assert!(session.load_workspace_file(ci, dir.join("api/v1/one.hurl")));
session.collections[ci].entries[0].modified = true;
let mut app = GuiApp::for_test(session);
let ctx = egui::Context::default();
let over_tree = egui::pos2(80.0, 200.0);
for _ in 0..2 {
let c = draw_app(&ctx, &mut app, over_tree);
assert!(c.is_empty(), "the workspace tab clashed: {c:?}");
}
for active in [0usize, ci, 0, ci] {
app.session.activate_tab(active);
for _ in 0..2 {
let c = draw_app(&ctx, &mut app, over_tree);
assert!(c.is_empty(), "activating tab {active} clashed: {c:?}");
}
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_overlapped_click_goes_to_the_widget_registered_last() {
let ctx = egui::Context::default();
let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(100.0, 100.0));
let mut input = egui::RawInput::default();
input
.events
.push(egui::Event::PointerMoved(egui::pos2(50.0, 50.0)));
let (mut background, mut row) = (false, false);
for _ in 0..3 {
let _ = ctx.run_ui(input.clone(), |ui| {
let bg = ui.interact(rect, ui.id().with("background"), egui::Sense::click());
let r = ui.interact(rect, ui.id().with("row"), egui::Sense::click());
background = bg.hovered();
row = r.hovered();
});
}
assert!(row, "the row, registered last, is the one hovered");
assert!(
!background,
"so the background behind it never steals the row's clicks"
);
}
fn entry(title: &str) -> HurlEntry {
let mut e = HurlEntry::default();
e.title = title.into();
e.method = "GET".into();
e
}
#[test]
fn duplicate_gives_the_copy_a_fresh_title_and_lands_it_right_after_the_original() {
let mut session = crate::session::Session::default();
session.collections[0].entries = vec![entry("Login"), entry("Logout")];
let ci = 0;
let mut app = GuiApp::for_test(session);
apply_actions(
&mut app,
ci,
Actions {
duplicate: Some(0),
..Default::default()
},
);
let titles: Vec<&str> = app.session.collections[ci]
.entries
.iter()
.map(|e| e.title.as_str())
.collect();
assert_eq!(titles, vec!["Login", "Login (2)", "Logout"]);
assert_eq!(app.session.collections[ci].selected_entry, 1);
}
#[test]
fn duplicate_never_carries_over_the_originals_last_response() {
let mut session = crate::session::Session::default();
let mut original = entry("Login");
original.last_response = Some(crate::http::ApiResponse {
status: 200,
..Default::default()
});
session.collections[0].entries = vec![original];
let ci = 0;
let mut app = GuiApp::for_test(session);
apply_actions(
&mut app,
ci,
Actions {
duplicate: Some(0),
..Default::default()
},
);
let copy = &app.session.collections[ci].entries[1];
assert!(
copy.last_response.is_none(),
"a copy that has never been sent shouldn't inherit a result it didn't produce"
);
}
#[test]
fn dragging_a_request_downwards_lands_it_in_the_gap_it_was_dropped_into() {
let mut session = crate::session::Session::default();
session.collections[0].entries = vec![entry("a"), entry("b"), entry("c"), entry("d")];
let ci = 0;
let mut app = GuiApp::for_test(session);
apply_actions(
&mut app,
ci,
Actions {
reorder: Some((0, 3)),
..Default::default()
},
);
assert_eq!(titles(&app, ci), vec!["b", "c", "a", "d"]);
assert!(
app.session.collections[ci].structure_modified,
"the order is what Run All follows, so this is an unsaved edit"
);
}
#[test]
fn dragging_a_request_upwards_lands_it_in_the_gap_it_was_dropped_into() {
let mut session = crate::session::Session::default();
session.collections[0].entries = vec![entry("a"), entry("b"), entry("c"), entry("d")];
let ci = 0;
let mut app = GuiApp::for_test(session);
apply_actions(
&mut app,
ci,
Actions {
reorder: Some((3, 1)),
..Default::default()
},
);
assert_eq!(titles(&app, ci), vec!["a", "d", "b", "c"]);
}
#[test]
fn a_drag_that_goes_nowhere_is_not_an_edit() {
let mut session = crate::session::Session::default();
session.collections[0].entries = vec![entry("a"), entry("b"), entry("c")];
let ci = 0;
let mut app = GuiApp::for_test(session);
for before in [1, 2] {
apply_actions(
&mut app,
ci,
Actions {
reorder: Some((1, before)),
..Default::default()
},
);
}
assert_eq!(titles(&app, ci), vec!["a", "b", "c"]);
assert!(!app.session.collections[ci].structure_modified);
}
#[test]
fn the_request_filter_flattens_the_tree_and_matches_the_whole_title() {
let mut session = crate::session::Session::default();
session.collections[0].entries = vec![
entry("Auth/Login"),
entry("Users/List"),
entry("Auth/Logout"),
];
let col = &mut session.collections[0];
col.list_query = "log".into();
assert!(col.list_filter_active());
assert_eq!(
crate::tree::rows_matching(&col.entries, &col.list_query),
vec![crate::tree::Row::Entry(0), crate::tree::Row::Entry(2)],
);
col.list_query = " ".into();
assert!(!col.list_filter_active());
}
fn titles(app: &GuiApp, ci: usize) -> Vec<&str> {
app.session.collections[ci]
.entries
.iter()
.map(|e| e.title.as_str())
.collect()
}
#[test]
fn deleting_a_request_records_it_for_undo_and_restoring_reinserts_it() {
let mut session = crate::session::Session::default();
session.collections[0].entries = vec![entry("a"), entry("b"), entry("c")];
let ci = 0;
let mut app = GuiApp::for_test(session);
app.session.confirm_on_delete_request = false;
apply_actions(
&mut app,
ci,
Actions {
delete: Some(1),
..Default::default()
},
);
assert_eq!(
app.session.collections[ci]
.entries
.iter()
.map(|e| e.title.as_str())
.collect::<Vec<_>>(),
vec!["a", "c"]
);
assert_eq!(app.session.collections[ci].deleted_entries.len(), 1);
app.undo_delete_request();
assert_eq!(
app.session.collections[ci]
.entries
.iter()
.map(|e| e.title.as_str())
.collect::<Vec<_>>(),
vec!["a", "b", "c"]
);
assert!(app.session.collections[ci].deleted_entries.is_empty());
assert_eq!(app.session.collections[ci].selected_entry, 1);
}
#[test]
fn run_all_confirm_counts_flags_only_the_non_get_requests() {
let get_only = vec![entry("a"), entry("b")];
assert_eq!(run_all_confirm_counts(&get_only), (2, 0));
let mut mixed = get_only.clone();
let mut post = entry("c");
post.method = "post".into();
mixed.push(post);
assert_eq!(run_all_confirm_counts(&mixed), (3, 1));
assert_eq!(run_all_confirm_counts(&[]), (0, 0));
}
#[test]
fn request_run_all_asks_only_when_a_write_is_in_the_collection() {
let mut session = crate::session::Session::default();
session.collections[0].entries = vec![entry("a"), entry("b")];
let mut app = GuiApp::for_test(session);
app.request_run_all(0);
assert!(
app.dialog.is_none(),
"a read-only collection runs with no prompt"
);
let mut post = entry("c");
post.method = "POST".into();
app.session.collections[0].entries.push(post);
app.request_run_all(0);
match &app.dialog {
Some(Dialog::ConfirmRunAll { ci, total, non_get }) => {
assert_eq!((*ci, *total, *non_get), (0, 3, 1));
}
_ => panic!("a collection with a write asks first, and says how many"),
}
}
#[test]
fn nav_entry_order_follows_the_file_order() {
let mut session = crate::session::Session::default();
session.collections[0].entries = vec![
entry("Root"),
entry("Users/List"),
entry("Auth/Login"),
entry("Auth/Logout"),
];
let col = &session.collections[0];
assert_eq!(nav_entry_order(col), vec![0, 1, 2, 3]);
}
#[test]
fn nav_entry_order_reuses_a_folder_that_the_file_returns_to() {
let mut session = crate::session::Session::default();
session.collections[0].entries = vec![
entry("Auth/Login"),
entry("Users/List"),
entry("Auth/Logout"),
];
let col = &session.collections[0];
assert_eq!(nav_entry_order(col), vec![0, 2, 1]);
}
#[test]
fn nav_entry_order_follows_the_sort_mode() {
let mut session = crate::session::Session::default();
session.collections[0].entries = vec![
entry("Zed/One"),
entry("loose"),
entry("Abe/Solo"),
entry("Zed/Two"),
];
let order = |s: &crate::session::Session| nav_entry_order(&s.collections[0]);
assert_eq!(order(&session), vec![0, 3, 1, 2], "the file's own order");
session.collections[0].list_sort = crate::tree::SortMode::Alpha;
assert_eq!(order(&session), vec![2, 1, 0, 3]);
session.collections[0].list_sort = crate::tree::SortMode::ReverseAlpha;
assert_eq!(order(&session), vec![3, 0, 1, 2]);
session.collections[0].list_sort = crate::tree::SortMode::File;
assert_eq!(order(&session), vec![0, 3, 1, 2]);
}
#[test]
fn nav_entry_order_follows_the_filter_when_one_is_typed() {
let mut session = crate::session::Session::default();
session.collections[0].entries = vec![
entry("Auth/Login"),
entry("Users/List"),
entry("Auth/Logout"),
];
session.collections[0].list_query = "log".into();
let col = &session.collections[0];
assert_eq!(nav_entry_order(col), vec![0, 2]);
}
#[test]
fn context_menu_delete_asks_before_removing_when_the_guard_is_on() {
let mut session = crate::session::Session::default();
session.collections[0].entries = vec![entry("a"), entry("b")];
let ci = 0;
let mut app = GuiApp::for_test(session);
assert!(
app.session.confirm_on_delete_request,
"the guard is on by default, like the environment one"
);
apply_actions(
&mut app,
ci,
Actions {
delete: Some(1),
..Default::default()
},
);
match &app.dialog {
Some(Dialog::ConfirmDeleteRequest { ci: dci, idx, name }) => {
assert_eq!((*dci, *idx), (0, 1));
assert_eq!(name, "b");
}
_ => panic!("a delete confirmation naming the request should be up"),
}
assert_eq!(
app.session.collections[ci].entries.len(),
2,
"nothing is removed until the prompt is answered"
);
}
fn ws_urls(app: &GuiApp, ci: usize) -> Vec<String> {
app.session.collections[ci]
.entries
.iter()
.map(|e| e.url.clone())
.collect()
}
#[test]
fn reordering_a_request_within_a_workspace_collection_moves_it_and_marks_the_file_unsaved() {
redirect_saved_state();
let dir = ws_tmp("wsreorder");
let one = dir.join("api/v1/one.hurl");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
session.active_tab = ci;
assert!(session.load_workspace_file(ci, one.clone()));
let mut app = GuiApp::for_test(session);
assert_eq!(
ws_urls(&app, ci),
vec!["https://example.com/a", "https://example.com/a2"]
);
apply_ws_action(
&mut app,
ci,
WsAction::ReorderRequest {
collection: one.clone(),
from: 0,
before: 2,
},
);
assert_eq!(
ws_urls(&app, ci),
vec!["https://example.com/a2", "https://example.com/a"],
"the request landed in the gap it was dropped into"
);
assert!(
app.session.collections[ci].structure_modified,
"a reorder is an unsaved structural edit, the same as in a plain tab"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_workspace_reorder_addressed_to_a_different_file_leaves_the_loaded_one_untouched() {
redirect_saved_state();
let dir = ws_tmp("wsreorderxfile");
let one = dir.join("api/v1/one.hurl");
let two = dir.join("api/v1/two.hurl");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
session.active_tab = ci;
assert!(session.load_workspace_file(ci, one.clone()));
let mut app = GuiApp::for_test(session);
let before = ws_urls(&app, ci);
apply_ws_action(
&mut app,
ci,
WsAction::ReorderRequest {
collection: two.clone(),
from: 0,
before: 2,
},
);
assert_eq!(
before,
ws_urls(&app, ci),
"the loaded file's order is unchanged"
);
assert!(
!app.session.collections[ci].structure_modified,
"and nothing is marked unsaved"
);
assert_eq!(
app.session.collections[ci].path.as_deref(),
Some(one.as_path()),
"nor is a different file loaded — a reorder never load-then-acts"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_workspace_request_menu_renames_duplicates_and_deletes_through_the_plain_lists_handlers()
{
redirect_saved_state();
let dir = ws_tmp("wsmenu");
let one = dir.join("api/v1/one.hurl");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
session.active_tab = ci;
assert!(session.load_workspace_file(ci, one.clone()));
let mut app = GuiApp::for_test(session);
apply_ws_action(
&mut app,
ci,
WsAction::RequestAction {
collection: one.clone(),
idx: 1,
loaded: true,
kind: ReqEdit::Rename,
},
);
match &app.dialog {
Some(Dialog::Rename {
target: RenameTarget::Request { ci: dci, idx },
..
}) => assert_eq!((*dci, *idx), (ci, 1)),
_ => panic!("Rename should raise the request rename dialog"),
}
app.dialog = None;
let before = app.session.collections[ci].entries.len();
apply_ws_action(
&mut app,
ci,
WsAction::RequestAction {
collection: one.clone(),
idx: 0,
loaded: true,
kind: ReqEdit::Duplicate,
},
);
assert_eq!(
app.session.collections[ci].entries.len(),
before + 1,
"a copy was inserted"
);
assert_eq!(
app.session.collections[ci].selected_entry, 1,
"and selected, right after the original it came from"
);
app.session.confirm_on_delete_request = false;
let n = app.session.collections[ci].entries.len();
apply_ws_action(
&mut app,
ci,
WsAction::RequestAction {
collection: one.clone(),
idx: 0,
loaded: true,
kind: ReqEdit::Delete,
},
);
assert_eq!(
app.session.collections[ci].entries.len(),
n - 1,
"the request was deleted"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_request_action_on_a_not_loaded_row_loads_that_file_before_acting() {
redirect_saved_state();
let dir = ws_tmp("wsloadact");
let one = dir.join("api/v1/one.hurl");
let two = dir.join("api/v1/two.hurl");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
session.active_tab = ci;
assert!(session.load_workspace_file(ci, one.clone()));
let mut app = GuiApp::for_test(session);
app.session.confirm_on_delete_request = false;
assert_eq!(
app.session.collections[ci].path.as_deref(),
Some(one.as_path()),
"one.hurl is the loaded file, two.hurl is not"
);
apply_ws_action(
&mut app,
ci,
WsAction::RequestAction {
collection: two.clone(),
idx: 1,
loaded: false,
kind: ReqEdit::Delete,
},
);
assert_eq!(
app.session.collections[ci].path.as_deref(),
Some(two.as_path()),
"the addressed file is now the loaded one"
);
assert_eq!(
ws_urls(&app, ci),
vec!["https://example.com/b"],
"and its second request — not one.hurl's — was the one deleted"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn renaming_the_loaded_workspace_file_repoints_the_tab_that_had_it_open() {
redirect_saved_state();
let dir = ws_tmp("wsrenload");
let one = dir.join("api/v1/one.hurl");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
session.active_tab = ci;
assert!(session.load_workspace_file(ci, one.clone()));
let mut app = GuiApp::for_test(session);
rename_workspace_item(&mut app, ci, &one, "renamed");
let dest = dir.join("api/v1/renamed.hurl");
assert!(dest.exists(), "the file is at its new name on disk");
assert!(!one.exists(), "and gone from the old one");
assert_eq!(
app.session.collections[ci].path.as_deref(),
Some(dest.as_path()),
"the tab follows the file it had loaded"
);
assert!(
matches!(
app.session.status,
Some(crate::i18n::Status::WsItemRenamed(_))
),
"and it reports the rename"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_file_rename_menu_entry_targets_the_file_not_a_request() {
redirect_saved_state();
let dir = ws_tmp("wsrenmenu");
let health = dir.join("health.hurl");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
session.active_tab = ci;
let mut app = GuiApp::for_test(session);
apply_ws_action(
&mut app,
ci,
WsAction::RenameItem {
path: health.clone(),
},
);
match &app.dialog {
Some(Dialog::Rename {
target: RenameTarget::WorkspaceItem { ci: dci, path },
text,
}) => {
assert_eq!(*dci, ci);
assert_eq!(path, &health);
assert_eq!(text, "health.hurl", "seeded with the current name");
}
_ => panic!("file Rename should raise a WorkspaceItem rename dialog"),
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn deleting_the_loaded_workspace_file_unloads_the_tab() {
redirect_saved_state();
let dir = ws_tmp("wsdelload");
let one = dir.join("api/v1/one.hurl");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
session.active_tab = ci;
assert!(session.load_workspace_file(ci, one.clone()));
let mut app = GuiApp::for_test(session);
assert!(!app.session.collections[ci].entries.is_empty());
delete_workspace_item(&mut app, ci, &one);
assert!(!one.exists(), "the file is gone from disk");
assert_eq!(
app.session.collections[ci].path, None,
"the tab no longer has a loaded file"
);
assert!(
app.session.collections[ci].entries.is_empty(),
"and isn't left showing the deleted file's requests"
);
assert!(
matches!(
app.session.status,
Some(crate::i18n::Status::WsItemDeleted(_))
),
"and it reports the delete"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn deleting_a_workspace_folder_removes_it_and_forgets_its_children() {
redirect_saved_state();
let dir = ws_tmp("wsdelfolder");
let api = dir.join("api");
let v1 = dir.join("api/v1");
let one = v1.join("one.hurl");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
session.active_tab = ci;
assert!(session.load_workspace_file(ci, one.clone()));
let mut app = GuiApp::for_test(session);
app.session.collections[ci]
.workspace_expanded
.insert(api.clone());
app.session.collections[ci]
.workspace_expanded
.insert(v1.clone());
app.session.collections[ci].workspace_selected = Some(one.clone());
delete_workspace_item(&mut app, ci, &api);
assert!(
!api.exists(),
"the folder and its subtree are gone from disk"
);
assert_eq!(
app.session.collections[ci].path, None,
"the loaded file, which was inside, is unloaded"
);
assert!(
!app.session.collections[ci]
.workspace_expanded
.iter()
.any(|p| p.starts_with(&api)),
"no expanded row under the deleted folder survives"
);
assert_eq!(
app.session.collections[ci].workspace_selected, None,
"and the remembered selection, which was inside, is cleared"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_file_delete_menu_entry_always_confirms_and_counts_a_folder() {
redirect_saved_state();
let dir = ws_tmp("wsdelconfirm");
let api = dir.join("api");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
session.active_tab = ci;
let mut app = GuiApp::for_test(session);
app.session.confirm_on_delete_request = false;
apply_ws_action(
&mut app,
ci,
WsAction::DeleteItem {
path: api.clone(),
is_dir: true,
},
);
match &app.dialog {
Some(Dialog::DeleteWorkspaceItem {
path,
is_dir,
file_count,
..
}) => {
assert_eq!(path, &api);
assert!(*is_dir, "api is a folder");
assert_eq!(
*file_count, 4,
"api/v1 holds two collections, an env and a report — four files"
);
}
_ => panic!("Delete should always raise the confirmation dialog"),
}
assert!(api.exists(), "the folder is still there until confirmed");
let _ = std::fs::remove_dir_all(&dir);
}
fn ws_key(app: &mut GuiApp, ctx: &egui::Context, ci: usize, key: egui::Key) {
let mut input = egui::RawInput::default();
input.events.push(egui::Event::Key {
key,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
});
input.modifiers = egui::Modifiers::NONE;
let _ = ctx.run_ui(input, |ui| handle_ws_tree_keys(app, ui.ctx(), ci));
}
fn folder_row(app: &GuiApp, ci: usize, path: &std::path::Path) -> usize {
app.session.collections[ci]
.ws_rows()
.iter()
.position(|r| matches!(r, WsRow::Folder { path: p, .. } if p == path))
.unwrap_or_else(|| panic!("no folder row for {}", path.display()))
}
#[test]
fn the_workspace_tree_cursor_moves_with_the_arrows_and_clamps_at_both_ends() {
redirect_saved_state();
let dir = ws_tmp("wskeymove");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
session.active_tab = ci;
let mut app = GuiApp::for_test(session);
let last = app.session.collections[ci].ws_rows().len() - 1;
assert!(last >= 1, "the fixture lists at least two top-level rows");
app.session.collections[ci].list_cursor = 0;
let ctx = egui::Context::default();
ws_key(&mut app, &ctx, ci, egui::Key::ArrowDown);
assert_eq!(app.session.collections[ci].list_cursor, 1);
assert!(
app.reveal_selected,
"a keyboard move asks the row into view for the next render"
);
ws_key(&mut app, &ctx, ci, egui::Key::End);
assert_eq!(
app.session.collections[ci].list_cursor, last,
"End jumps to the last row"
);
ws_key(&mut app, &ctx, ci, egui::Key::ArrowDown);
assert_eq!(
app.session.collections[ci].list_cursor, last,
"Down at the bottom clamps rather than wrapping"
);
ws_key(&mut app, &ctx, ci, egui::Key::Home);
assert_eq!(
app.session.collections[ci].list_cursor, 0,
"Home jumps to the first row"
);
ws_key(&mut app, &ctx, ci, egui::Key::ArrowUp);
assert_eq!(
app.session.collections[ci].list_cursor, 0,
"Up at the top clamps rather than wrapping"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn right_expands_a_collapsed_folder_and_left_collapses_it() {
redirect_saved_state();
let dir = ws_tmp("wskeyexpand");
let api = dir.join("api");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
session.active_tab = ci;
let mut app = GuiApp::for_test(session);
let ctx = egui::Context::default();
let api_row = folder_row(&app, ci, &api);
app.session.collections[ci].list_cursor = api_row;
assert!(
!app.session.collections[ci]
.workspace_expanded
.contains(&api),
"the api folder starts collapsed"
);
ws_key(&mut app, &ctx, ci, egui::Key::ArrowRight);
assert!(
app.session.collections[ci]
.workspace_expanded
.contains(&api),
"Right expands a collapsed folder"
);
assert_eq!(
app.session.collections[ci].list_cursor, api_row,
"the cursor stays on the folder it just opened"
);
ws_key(&mut app, &ctx, ci, egui::Key::ArrowLeft);
assert!(
!app.session.collections[ci]
.workspace_expanded
.contains(&api),
"Left collapses the folder it is looking into"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn right_steps_into_an_open_folder_and_left_climbs_to_the_parent() {
redirect_saved_state();
let dir = ws_tmp("wskeytree");
let api = dir.join("api");
let v1 = dir.join("api/v1");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
session.active_tab = ci;
session.collections[ci]
.workspace_expanded
.insert(api.clone());
let mut app = GuiApp::for_test(session);
let ctx = egui::Context::default();
let api_row = folder_row(&app, ci, &api);
let v1_row = folder_row(&app, ci, &v1);
assert_eq!(v1_row, api_row + 1, "v1 is api's first child in the tree");
app.session.collections[ci].list_cursor = api_row;
ws_key(&mut app, &ctx, ci, egui::Key::ArrowRight);
assert_eq!(
app.session.collections[ci].list_cursor, v1_row,
"Right on an already-open folder steps onto its first child"
);
assert!(
!app.session.collections[ci].workspace_expanded.contains(&v1),
"v1 is collapsed, so Left climbs out rather than closing it"
);
ws_key(&mut app, &ctx, ci, egui::Key::ArrowLeft);
assert_eq!(
app.session.collections[ci].list_cursor, api_row,
"Left on a collapsed child climbs to the parent row"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn enter_runs_a_request_and_toggles_a_folder() {
redirect_saved_state();
let dir = ws_tmp("wskeyenter");
let api = dir.join("api");
let one = dir.join("api/v1/one.hurl");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
session.active_tab = ci;
let mut app = GuiApp::for_test(session);
let ctx = egui::Context::default();
let api_row = folder_row(&app, ci, &api);
app.session.collections[ci].list_cursor = api_row;
ws_key(&mut app, &ctx, ci, egui::Key::Enter);
assert!(
app.session.collections[ci]
.workspace_expanded
.contains(&api),
"Enter on a folder toggles it"
);
assert!(app.session.load_workspace_file(ci, one.clone()));
let rows = app.session.collections[ci].ws_rows();
let (req_row, idx) = rows
.iter()
.enumerate()
.find_map(|(row, r)| match r {
WsRow::Request {
idx, loaded: true, ..
} => Some((row, *idx)),
_ => None,
})
.expect("the loaded file lists a request row");
app.session.collections[ci].list_cursor = req_row;
ws_key(&mut app, &ctx, ci, egui::Key::Enter);
assert_eq!(
app.session.collections[ci].selected_entry, idx,
"Enter selects the request it is about to run"
);
assert_eq!(
app.session.collections[ci].entries[idx].last_run,
crate::hurl::RunStatus::Running,
"and starts it running"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn f2_and_delete_reach_the_file_on_a_file_row_and_the_request_on_a_request_row() {
redirect_saved_state();
let dir = ws_tmp("wskeyedit");
let health = dir.join("health.hurl");
let one = dir.join("api/v1/one.hurl");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
session.active_tab = ci;
session.confirm_on_delete_request = false;
let mut app = GuiApp::for_test(session);
let ctx = egui::Context::default();
let file_row = app.session.collections[ci]
.ws_rows()
.iter()
.position(|r| matches!(r, WsRow::Collection { path, .. } if path == &health))
.expect("health.hurl is a top-level collection row");
app.session.collections[ci].list_cursor = file_row;
ws_key(&mut app, &ctx, ci, egui::Key::F2);
assert!(
matches!(
app.dialog,
Some(Dialog::Rename {
target: RenameTarget::WorkspaceItem { .. },
..
})
),
"F2 on a file renames the workspace item, not a request"
);
app.dialog = None;
ws_key(&mut app, &ctx, ci, egui::Key::Delete);
assert!(
matches!(app.dialog, Some(Dialog::DeleteWorkspaceItem { .. })),
"Delete on a file always raises the disk-delete confirmation"
);
assert!(health.exists(), "and nothing is gone until it is confirmed");
app.dialog = None;
assert!(app.session.load_workspace_file(ci, one.clone()));
let rows = app.session.collections[ci].ws_rows();
let req_row = rows
.iter()
.position(|r| matches!(r, WsRow::Request { loaded: true, .. }))
.expect("the loaded file lists a request row");
app.session.collections[ci].list_cursor = req_row;
ws_key(&mut app, &ctx, ci, egui::Key::F2);
assert!(
matches!(
app.dialog,
Some(Dialog::Rename {
target: RenameTarget::Request { .. },
..
})
),
"F2 on a request renames the request"
);
app.dialog = None;
let n = app.session.collections[ci].entries.len();
ws_key(&mut app, &ctx, ci, egui::Key::Delete);
assert!(
app.dialog.is_none(),
"the request-delete guard is off, so a request delete acts without a prompt"
);
assert_eq!(
app.session.collections[ci].entries.len(),
n - 1,
"and the request under the cursor is gone"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn clicking_a_tree_row_moves_the_keyboard_cursor_to_it() {
redirect_saved_state();
let dir = ws_tmp("wskeyclick");
let api = dir.join("api");
let mut session = crate::session::Session::default();
session.collections.clear();
let ci = session.open_workspace(dir.clone());
session.active_tab = ci;
let mut app = GuiApp::for_test(session);
app.focus = crate::gui::Focus::List;
let ctx = egui::Context::default();
let screen = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 600.0));
let frame = |events: Vec<egui::Event>| egui::RawInput {
screen_rect: Some(screen),
events,
..Default::default()
};
let api_row = folder_row(&app, ci, &api);
app.session.collections[ci].list_cursor = api_row + 1;
let out = ctx.run_ui(frame(Vec::new()), |ui| {
crate::gui::requests::ui(&mut app, ui)
});
let row = texts(&out.shapes)
.into_iter()
.find(|(t, _)| t.contains("api"))
.expect("the api folder row is drawn")
.1;
let at = egui::pos2(row.center().x, row.center().y);
let mut events = vec![egui::Event::PointerMoved(at)];
for pressed in [true, false] {
events.push(egui::Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
});
}
let _ = ctx.run_ui(frame(events), |ui| crate::gui::requests::ui(&mut app, ui));
assert_eq!(
app.session.collections[ci].list_cursor, api_row,
"clicking a row hands the keyboard cursor to it, so the two don't fight"
);
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod revert_menu_tests {
use super::*;
use crate::gui::app::Dialog;
#[test]
fn reverting_a_request_asks_before_throwing_the_edits_away() {
let dir = std::env::temp_dir().join(format!(
"paperboy_revert_{}_{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("c.hurl");
std::fs::write(&path, "# Folder/Login\nGET https://example.com/a\n").unwrap();
let mut session = crate::session::Session::default();
session.collections.clear();
let text = std::fs::read_to_string(&path).unwrap();
assert!(session.load_collection_text("c".into(), &text, Some(path.clone())));
let ci = session.active_tab;
let mut app = GuiApp::for_test(session);
super::ask_revert_request(&mut app, ci, path.clone(), 0);
match &app.dialog {
Some(Dialog::RevertToSaved {
ci: dci,
path: dp,
entry,
name,
}) => {
assert_eq!((*dci, entry.unwrap()), (ci, 0));
assert_eq!(dp, &path);
assert_eq!(name, "Login");
}
_ => panic!("a revert must be confirmed, since it has no undo"),
}
app.dialog = None;
app.session.collections[ci]
.entries
.push(crate::hurl::HurlEntry {
method: "GET".into(),
url: "https://example.com/new".into(),
user_added: true,
..Default::default()
});
let added = app.session.collections[ci].entries.len() - 1;
super::ask_revert_request(&mut app, ci, path.clone(), added);
assert!(app.dialog.is_none(), "nothing to confirm");
assert!(matches!(
app.session.status,
Some(crate::i18n::Status::RequestHasNoSavedVersion)
));
let _ = std::fs::remove_dir_all(&dir);
}
}