diff --git a/crates/crabapi/Cargo.toml b/crates/crabapi/Cargo.toml index af9f8e1..88432e6 100644 --- a/crates/crabapi/Cargo.toml +++ b/crates/crabapi/Cargo.toml @@ -11,10 +11,8 @@ iced = ["dep:iced", "dep:iced_highlighter"] clap = "4.5.31" const_format = "0.2.34" http = "1.2.0" +rfd = "0.15.2" reqwest = "0.12.12" tokio = { version = "1", features = ["full"] } - -# gui options are optional -iced = { version = "0.13.1", optional = true, features = ["advanced"] } +iced = { version = "0.13.1", optional = true, features = ["advanced", "tokio"] } iced_highlighter = { version = "0.13.0", optional = true } - diff --git a/crates/crabapi/src/gui/iced/mod.rs b/crates/crabapi/src/gui/iced/mod.rs index f8b4b4a..5903abb 100644 --- a/crates/crabapi/src/gui/iced/mod.rs +++ b/crates/crabapi/src/gui/iced/mod.rs @@ -1,19 +1,20 @@ // internal mods mod default_styles; -use http::{HeaderMap, HeaderName}; -use std::collections::HashMap; -// dependencies use crate::core::requests; +use crate::core::requests::{Method, constants, send_requests, validators}; +use http::{HeaderMap, HeaderName}; use iced; use iced::widget::text_editor::{Action, Content}; -use iced::widget::{Button, Row, Text, TextInput, scrollable, text_editor}; -use iced::widget::{button, column, container, pick_list, row}; +use iced::widget::{Button, Row, Space, Text, TextInput}; +use iced::widget::{button, column, container, pick_list, radio, row, scrollable, text_editor}; use iced::{Alignment, Center, Element, Length, Task}; use iced_highlighter::Highlighter; use reqwest::{Body, Client}; -// internal dependencies -use crate::core::requests::{Method, constants, send_requests, validators}; +use std::collections::HashMap; +use std::io; +use std::path::PathBuf; +use std::sync::Arc; pub fn init() { iced::run(GUI::title, GUI::update, GUI::view).unwrap() @@ -30,6 +31,23 @@ enum Message { SendRequest, ResponseBodyChanged(String), ResponseBodyText(Action), + BodyTypeChanged(BodyType), + BodyContentChanged(text_editor::Action), + BodyContentOpenFile, + BodyContentFileOpened(Result<(PathBuf, Arc), FileOpenDialogError>), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BodyType { + Empty, + File, + Text, +} + +#[derive(Debug, Clone)] +pub enum FileOpenDialogError { + DialogClosed, + IoError(io::ErrorKind), } #[derive(Debug)] @@ -42,6 +60,10 @@ struct GUI { url_input_valid: bool, header_input: Vec<(String, String)>, response_body: Content, + body_content: text_editor::Content, + body_type_select: Option, + body_file_path: Option, + body_file_content: Option>, } impl GUI { @@ -54,6 +76,10 @@ impl GUI { url_input_valid: false, header_input: vec![(String::new(), String::new())], response_body: Content::with_text("Response body will go here..."), + body_content: text_editor::Content::default(), + body_type_select: Some(BodyType::Text), + body_file_path: None, + body_file_content: None, } } @@ -140,6 +166,33 @@ impl GUI { Task::none() } + Message::BodyTypeChanged(body_type) => { + self.body_type_select = Some(body_type); + Task::none() + } + Message::BodyContentChanged(action) => { + self.body_content.perform(action); + Task::none() + } + Message::BodyContentOpenFile => { + Task::perform(open_file(), Message::BodyContentFileOpened) + } + Message::BodyContentFileOpened(result) => { + match result { + Ok((path, content)) => { + self.body_file_content = Some(content); + self.body_file_path = Some(path); + } + Err(error) => { + // TODO: use tracing + println!("Error opening file: {:?}", error); + if let FileOpenDialogError::IoError(kind) = error { + println!("Error kind: {:?}", kind); + } + } + } + Task::none() + } } } @@ -148,25 +201,20 @@ impl GUI { let request_row = self.view_request(); // ROW: Headers - let headers_column = self.view_request_headers(); + let headers_row = self.view_request_headers(); + + // ROW: Body + let body_row = self.view_request_body(); // ROW: Response let response_row = self.view_response(); - column![ - request_row, - container(headers_column) - .width(Length::Fill) - .padding(default_styles::padding()), - container(response_row) - .align_x(Center) - .width(Length::Fill) - .padding(default_styles::padding()), - ] - .into() + column![request_row, headers_row, body_row, response_row].into() } fn view_request(&self) -> Element { + let title_row = Self::view_request_row_setup(row![Self::view_request_title()]); + let url_input = self.view_request_url_input(); let method_input = self.view_request_method_input(); @@ -175,11 +223,17 @@ impl GUI { let request_row = Self::view_request_row_setup(row![method_input, url_input, send_button]); - request_row.into() + column![title_row, request_row].into() } // VIEW REQUEST - GENERAL + fn view_request_title() -> Element<'static, Message> { + Text::new("Request") + .size(default_styles::input_size()) + .into() + } + fn view_request_url_input(&self) -> Element { let url_input_icon = Self::view_request_url_input_icon(self.url_input_valid); let url_input = TextInput::new("Enter URI", &self.url_input) @@ -229,6 +283,13 @@ impl GUI { // VIEW REQUEST - HEADERS fn view_request_headers(&self) -> Element { + container(self.view_request_headers_inner()) + .width(Length::Fill) + .padding(default_styles::padding()) + .into() + } + + fn view_request_headers_inner(&self) -> Element { let headers_title = Self::view_request_headers_title(); let headers_column = self.view_request_headers_column(); @@ -241,7 +302,9 @@ impl GUI { } fn view_request_headers_title() -> Element<'static, Message> { - Text::new("Headers").size(16).into() + Text::new("Headers") + .size(default_styles::input_size()) + .into() } fn view_request_headers_column(&self) -> Element { @@ -280,7 +343,109 @@ impl GUI { .into() } + // VIEW REQUEST - BODY + + fn view_request_body(&self) -> Element { + container(self.view_request_body_inner()) + .width(Length::Fill) + .padding(default_styles::padding()) + .into() + } + + fn view_request_body_inner(&self) -> Element { + let body_title = Self::view_request_body_title(); + + let radio_buttons = self.view_request_body_radio_buttons(); + + let content = self.view_request_body_content(); + + column!(body_title, radio_buttons, content,) + .spacing(default_styles::spacing()) + .into() + } + + fn view_request_body_title() -> Element<'static, Message> { + Text::new("Body").size(default_styles::input_size()).into() + } + + fn view_request_body_radio_buttons(&self) -> Row { + let empty = radio( + "Empty", + BodyType::Empty, + self.body_type_select, + Message::BodyTypeChanged, + ); + + let text = radio( + "Text", + BodyType::Text, + self.body_type_select, + Message::BodyTypeChanged, + ); + let file = radio( + "File", + BodyType::File, + self.body_type_select, + Message::BodyTypeChanged, + ); + + row![empty, text, file].spacing(default_styles::spacing()) + } + + fn view_request_body_content(&self) -> Row { + let content = match self.body_type_select { + Some(BodyType::Empty) => row![], + Some(BodyType::File) => self.view_request_body_file(), + Some(BodyType::Text) => self.view_request_body_text(), + None => row![], + }; + + content + } + + fn view_request_body_text(&self) -> Row { + row![ + text_editor(&self.body_content) + .on_action(Message::BodyContentChanged) + .placeholder("Introduce body here...") + .size(default_styles::input_size()) + ] + } + + fn view_request_body_file(&self) -> Row { + let file_name_string = format!( + "File: {}", + self.body_file_path + .as_ref() + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|| "No file selected".to_string()) + ); + + row![ + Self::view_request_body_text_button(), + Space::new(default_styles::input_size(), default_styles::input_size()), + Text::new(file_name_string).size(default_styles::input_size()) + ] + .align_y(Center) + } + + fn view_request_body_text_button() -> Element<'static, Message> { + Button::new(Text::new("Select File").size(default_styles::input_size())) + .on_press(Message::BodyContentOpenFile) + .into() + } + + // VIEW RESPONSE + fn view_response(&self) -> Element<'_, Message> { + container(self.view_response_inner()) + .align_x(Center) + .width(Length::Fill) + .padding(default_styles::padding()) + .into() + } + + fn view_response_inner(&self) -> Element<'_, Message> { let label = Text::new("Response:").size(default_styles::input_size()); let body = text_editor(&self.response_body) .on_action(Message::ResponseBodyText) @@ -300,3 +465,26 @@ impl Default for GUI { GUI::new() } } + +async fn open_file() -> Result<(PathBuf, Arc), FileOpenDialogError> { + let picked_file = rfd::AsyncFileDialog::new() + .set_title("Open a file...") + .pick_file() + .await + .ok_or(FileOpenDialogError::DialogClosed)?; + + load_file(picked_file).await +} + +async fn load_file( + path: impl Into, +) -> Result<(PathBuf, Arc), FileOpenDialogError> { + let path = path.into(); + + let contents = tokio::fs::read_to_string(&path) + .await + .map(Arc::new) + .map_err(|error| FileOpenDialogError::IoError(error.kind()))?; + + Ok((path, contents)) +}