use std::collections::BTreeMap;
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 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 {
folders: BTreeMap<String, Node>,
entries: Vec<usize>,
}
fn build_tree(entries: &[HurlEntry]) -> 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);
let mut node = &mut root;
for seg in folders {
node = node.folders.entry(seg.clone()).or_default();
}
node.entries.push(i);
}
root
}
#[derive(Default)]
struct Actions {
select: Option<usize>,
run: Option<usize>,
rename: Option<usize>,
delete: Option<usize>,
}
fn render_node(
ui: &mut egui::Ui,
node: &Node,
entries: &[HurlEntry],
selected: usize,
theme: &GuiTheme,
id_prefix: &str,
lbl_untitled: &str,
lbl_run: &str,
lbl_rename: &str,
lbl_delete: &str,
lbl_edited: &str,
actions: &mut Actions,
) {
for (name, child) in &node.folders {
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,
child,
entries,
selected,
theme,
&salt,
lbl_untitled,
lbl_run,
lbl_rename,
lbl_delete,
lbl_edited,
actions,
);
},
);
}
for &i in &node.entries {
let entry = &entries[i];
let leaf = entry_path(&entry.title).pop().unwrap_or_default();
let label = if leaf.trim().is_empty() {
if entry.url.trim().is_empty() {
lbl_untitled.to_string()
} else {
entry.url.clone()
}
} else {
leaf
};
let (marker, ok) = run_marker(entry.last_run);
let is_sel = i == selected;
let row = ui
.push_id(("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.dim)
};
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, lbl_edited);
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
super::widgets::selectable(ui, is_sel, text)
})
.inner
})
.inner
})
.inner
})
.inner;
if row.clicked() {
actions.select = Some(i);
}
if row.double_clicked() {
actions.run = Some(i);
}
row.context_menu(|ui| {
if ui.button(lbl_run).clicked() {
actions.run = Some(i);
ui.close();
}
if ui.button(lbl_rename).clicked() {
actions.rename = Some(i);
ui.close();
}
if ui.button(lbl_delete).clicked() {
actions.delete = Some(i);
ui.close();
}
});
}
}
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_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_delete,
s.gui_edited_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 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.session.run_all_entries(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();
let selected = app.session.collections[ci].selected_entry;
let mut actions = Actions::default();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
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);
render_node(
ui,
&tree,
entries,
selected,
&theme,
"req",
lbl_untitled,
lbl_run,
lbl_rename,
lbl_delete,
lbl_edited,
&mut 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.delete {
let col = &mut app.session.collections[ci];
if i < col.entries.len() {
col.entries.remove(i);
if col.selected_entry >= col.entries.len() {
col.selected_entry = col.entries.len().saturating_sub(1);
}
col.invalidate_request_json();
}
}
if let Some(i) = actions.run {
app.session.collections[ci].selected_entry = i;
app.run_active();
}
}
const WS_INDENT: f32 = 14.0;
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,
},
}
#[derive(Clone, Debug)]
struct WsDrag(PathBuf);
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_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 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 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.session.run_all_entries(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();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
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 in &rows {
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(&resp, path.clone(), lbl_in_folder, s_new, &mut actions);
ws_drag_and_drop(ui, &resp, &theme, path, true, &mut actions);
folder_rects.push(resp.rect);
}
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| {
if !edited_file {
return None;
}
let hit = ui.button(lbl_revert_file).clicked();
ui.separator();
hit.then(|| WsAction::RevertFile(path.clone()))
},
);
ws_drag_and_drop(ui, &resp, &theme, path, false, &mut actions);
}
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 = if *loaded {
app.session.collections[ci]
.entries
.get(*idx)
.map(|e| run_marker(e.last_run))
.unwrap_or(("", true))
} else {
("", true)
};
let edited =
app.session.collections[ci].workspace_request_edited(collection, *idx);
let resp = ui
.push_id(("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.dim)
};
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(ui, is_sel, text),
)
.inner
},
)
.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 revertable = *loaded
&& loaded_path.as_deref() == Some(collection.as_path())
&& 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 !revertable {
return None;
}
let hit = ui.button(lbl_revert_req).clicked();
ui.separator();
hit.then(|| WsAction::RevertRequest {
collection: collection.clone(),
idx: *idx,
})
},
);
}
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(&resp, sibling_dir(path), lbl_in_folder, s_new, &mut actions);
ws_drag_and_drop(ui, &resp, &theme, path, false, &mut actions);
}
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 hit = ui.button(lbl_set_active_env).clicked();
ui.separator();
hit.then(|| WsAction::ActivateEnv(path.clone()))
},
);
ws_drag_and_drop(ui, &resp, &theme, path, false, &mut actions);
}
}
}
if let Some(root) = &ws_root {
ws_root_drop(ui, bg_rect, &folder_rects, &theme, root, &mut actions);
}
});
for action in header_new.into_iter().chain(actions) {
apply_ws_action(app, ci, action);
}
}
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, repoint};
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;
}
for col in &mut app.session.collections {
if let Some(p) = col.path.clone().and_then(|p| repoint(&p, src, &dest)) {
col.path = Some(p);
}
if let Some(p) = col
.workspace_selected
.clone()
.and_then(|p| repoint(&p, src, &dest))
{
col.workspace_selected = Some(p);
}
col.workspace_expanded = col
.workspace_expanded
.iter()
.map(|p| repoint(p, src, &dest).unwrap_or_else(|| p.clone()))
.collect();
col.workspace_titles = col
.workspace_titles
.drain()
.map(|(p, v)| (repoint(&p, src, &dest).unwrap_or(p), v))
.collect();
}
if let Some(ed) = app.report_editor.as_mut()
&& let Some(p) = ed.path().and_then(|p| repoint(p, src, &dest))
{
ed.report.path = Some(p);
}
reveal_in_tree(app, ci, &dest, &root);
app.session.status = Some(crate::i18n::Status::WsItemMoved(
crate::workspace::display_name(&root, &dest),
));
}
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 apply_ws_action(app: &mut GuiApp, ci: usize, action: WsAction) {
match &action {
WsAction::ToggleCollection { path, .. }
| WsAction::SelectRequest {
collection: path, ..
}
| WsAction::RunRequest {
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::RevertRequest { .. }
| WsAction::RevertFile(_) => {}
}
match action {
WsAction::RevertRequest { collection, idx } => {
let name = app.session.collections[ci]
.entries
.get(idx)
.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(super::app::Dialog::RevertToSaved {
ci,
path: collection,
entry: Some(idx),
name,
});
}
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::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::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) };
});
}
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)
}
#[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"
);
}
}