├── .gitignore ├── crates ├── core │ ├── src │ │ ├── import │ │ │ └── mod.rs │ │ ├── transformers │ │ │ └── mod.rs │ │ ├── perf │ │ │ ├── mod.rs │ │ │ └── runner.rs │ │ ├── curl │ │ │ └── mod.rs │ │ ├── lib.rs │ │ ├── utils.rs │ │ ├── ids.rs │ │ ├── persistence │ │ │ ├── mod.rs │ │ │ └── environment.rs │ │ └── http │ │ │ ├── request.rs │ │ │ └── environment.rs │ └── Cargo.toml ├── parsers │ ├── src │ │ ├── template.pest │ │ └── lib.rs │ └── Cargo.toml └── cli │ ├── Cargo.toml │ └── src │ ├── lib.rs │ ├── run.rs │ ├── test.rs │ └── color.rs ├── src ├── app │ ├── panels │ │ ├── http │ │ │ ├── panes.rs │ │ │ ├── panes │ │ │ │ ├── response │ │ │ │ │ ├── idle.rs │ │ │ │ │ ├── failed.rs │ │ │ │ │ ├── executing.rs │ │ │ │ │ └── mod.rs │ │ │ │ └── request │ │ │ │ │ ├── body_editor.rs │ │ │ │ │ ├── bulk_edit.rs │ │ │ │ │ └── body_view.rs │ │ │ ├── mod.rs │ │ │ └── action_bar.rs │ │ ├── mod.rs │ │ ├── perf │ │ │ └── mod.rs │ │ ├── collection │ │ │ ├── env_table.rs │ │ │ ├── mod.rs │ │ │ ├── env_editor.rs │ │ │ └── settings.rs │ │ └── cookie_store.rs │ ├── popups │ │ ├── update_confirmation.rs │ │ ├── app_settings.rs │ │ ├── mod.rs │ │ ├── name_popup.rs │ │ └── save_request.rs │ ├── mod.rs │ └── bottom_bar.rs ├── ids.rs ├── state │ ├── tabs │ │ ├── mod.rs │ │ ├── collection_tab.rs │ │ ├── history_tab.rs │ │ ├── perf_tab.rs │ │ ├── http_tab.rs │ │ └── cookies_tab.rs │ ├── utils.rs │ ├── environment.rs │ ├── response.rs │ └── popups.rs ├── components │ ├── lines.rs │ ├── editor.rs │ ├── modal.rs │ ├── bordered.rs │ ├── scrollable.rs │ ├── mod.rs │ ├── key_value_viewer.rs │ ├── helpers.rs │ ├── code_editor.rs │ ├── icon.rs │ ├── button_tabs.rs │ ├── editor │ │ ├── highlighters.rs │ │ └── undo_stack.rs │ ├── line_editor.rs │ ├── min_dimension.rs │ └── script_view.rs ├── subscription.rs ├── debug.rs ├── commands │ ├── dialog.rs │ └── perf.rs ├── main.rs └── hotkeys.rs ├── assets ├── 16x16.png ├── 32x32.png ├── 48x48.png ├── 128x128.png ├── 256x256.png ├── 512x512.png ├── 128x128@2x.png ├── 16x16@2x.png ├── 256x256@2x.png ├── 32x32@2x.png ├── 512x512@2x.png ├── sanchaar.ico ├── sanchaar.jpeg └── dmg-background.png ├── screenshots └── app.png ├── fonts └── HackNerdFont-Regular.ttf ├── .cargo └── config.toml ├── server ├── echo │ ├── wrangler.toml │ └── index.ts ├── package.json ├── .gitignore └── tsconfig.json ├── homebrew └── sanchaar.rb ├── .github └── workflows │ └── ci.yml ├── Cargo.toml └── Readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | test/ -------------------------------------------------------------------------------- /crates/core/src/import/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod postman; 2 | -------------------------------------------------------------------------------- /src/app/panels/http/panes.rs: -------------------------------------------------------------------------------- 1 | pub mod request; 2 | pub mod response; 3 | -------------------------------------------------------------------------------- /assets/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrjais/sanchaar/HEAD/assets/16x16.png -------------------------------------------------------------------------------- /assets/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrjais/sanchaar/HEAD/assets/32x32.png -------------------------------------------------------------------------------- /assets/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrjais/sanchaar/HEAD/assets/48x48.png -------------------------------------------------------------------------------- /crates/core/src/transformers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod request; 2 | // pub mod script; 3 | -------------------------------------------------------------------------------- /assets/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrjais/sanchaar/HEAD/assets/128x128.png -------------------------------------------------------------------------------- /assets/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrjais/sanchaar/HEAD/assets/256x256.png -------------------------------------------------------------------------------- /assets/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrjais/sanchaar/HEAD/assets/512x512.png -------------------------------------------------------------------------------- /assets/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrjais/sanchaar/HEAD/assets/128x128@2x.png -------------------------------------------------------------------------------- /assets/16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrjais/sanchaar/HEAD/assets/16x16@2x.png -------------------------------------------------------------------------------- /assets/256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrjais/sanchaar/HEAD/assets/256x256@2x.png -------------------------------------------------------------------------------- /assets/32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrjais/sanchaar/HEAD/assets/32x32@2x.png -------------------------------------------------------------------------------- /assets/512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrjais/sanchaar/HEAD/assets/512x512@2x.png -------------------------------------------------------------------------------- /assets/sanchaar.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrjais/sanchaar/HEAD/assets/sanchaar.ico -------------------------------------------------------------------------------- /assets/sanchaar.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrjais/sanchaar/HEAD/assets/sanchaar.jpeg -------------------------------------------------------------------------------- /screenshots/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrjais/sanchaar/HEAD/screenshots/app.png -------------------------------------------------------------------------------- /assets/dmg-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrjais/sanchaar/HEAD/assets/dmg-background.png -------------------------------------------------------------------------------- /fonts/HackNerdFont-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrjais/sanchaar/HEAD/fonts/HackNerdFont-Regular.ttf -------------------------------------------------------------------------------- /crates/core/src/perf/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod metrics; 2 | pub mod runner; 3 | 4 | pub use metrics::*; 5 | pub use runner::*; 6 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.'cfg(target_os = "linux")'] 2 | linker = "clang" 3 | rustflags = ["-C", "link-arg=-fuse-ld=mold"] 4 | -------------------------------------------------------------------------------- /src/ids.rs: -------------------------------------------------------------------------------- 1 | use iced::widget; 2 | 3 | pub const PERF_REQUEST_DROP_ZONE: widget::Id = widget::Id::new("perf_request_drop_zone"); 4 | -------------------------------------------------------------------------------- /src/state/tabs/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod collection_tab; 2 | pub mod cookies_tab; 3 | pub mod history_tab; 4 | pub mod http_tab; 5 | pub mod perf_tab; 6 | -------------------------------------------------------------------------------- /crates/core/src/curl/mod.rs: -------------------------------------------------------------------------------- 1 | mod generator; 2 | mod parser; 3 | 4 | pub use self::generator::generate_curl_command; 5 | pub use self::parser::parse_curl_command; 6 | -------------------------------------------------------------------------------- /crates/parsers/src/template.pest: -------------------------------------------------------------------------------- 1 | template = { SOI ~ (variable | text)* ~ EOI } 2 | variable = { "{{" ~ ident ~ "}}" } 3 | text = { (!variable ~ ANY)+ } 4 | ident = { (ASCII_ALPHANUMERIC | "_" | "-" | "!" | ".")+ } 5 | -------------------------------------------------------------------------------- /crates/parsers/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "parsers" 3 | version.workspace = true 4 | description.workspace = true 5 | edition.workspace = true 6 | 7 | [dependencies] 8 | pest.workspace = true 9 | pest_derive.workspace = true 10 | -------------------------------------------------------------------------------- /src/components/lines.rs: -------------------------------------------------------------------------------- 1 | use iced::widget::{Rule, rule}; 2 | 3 | pub fn horizontal_line<'a>(width: u16) -> Rule<'a, iced::Theme> { 4 | rule::horizontal(width as f32) 5 | } 6 | 7 | pub fn vertical_line<'a>(width: u16) -> Rule<'a, iced::Theme> { 8 | rule::vertical(width as f32) 9 | } 10 | -------------------------------------------------------------------------------- /server/echo/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "echo" 2 | main = "index.ts" 3 | compatibility_date = "2025-10-01" 4 | preview_urls = false 5 | 6 | [placement] 7 | mode = "smart" 8 | 9 | [observability] 10 | enabled = false 11 | 12 | [observability.logs] 13 | enabled = true 14 | head_sampling_rate = 1 15 | invocation_logs = true 16 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "deploy": "wrangler deploy", 7 | "dev": "wrangler dev", 8 | "start": "wrangler dev" 9 | }, 10 | "devDependencies": { 11 | "@cloudflare/workers-types": "^4.20231218.0", 12 | "typescript": "^5.0.4", 13 | "wrangler": "^4.42.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/subscription.rs: -------------------------------------------------------------------------------- 1 | use iced::Subscription; 2 | 3 | use crate::{app::AppMsg, hotkeys, state::AppState}; 4 | 5 | pub fn all(state: &AppState) -> Subscription { 6 | Subscription::batch([ 7 | state.plugins.manager.subscriptions().map(AppMsg::Plugin), 8 | state.plugins.auto_updater.listen().map(AppMsg::AutoUpdater), 9 | hotkeys::subscription(state), 10 | ]) 11 | } 12 | -------------------------------------------------------------------------------- /crates/core/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(mismatched_lifetime_syntaxes)] 2 | pub mod assertions; 3 | pub mod client; 4 | pub mod curl; 5 | pub mod http; 6 | pub mod ids; 7 | pub mod import; 8 | pub mod perf; 9 | pub mod persistence; 10 | pub mod scripting; 11 | pub mod transformers; 12 | pub mod utils; 13 | 14 | pub const APP_NAME: &str = "Sanchaar"; 15 | pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); 16 | -------------------------------------------------------------------------------- /src/debug.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "default")] 2 | pub use local::*; 3 | #[cfg(not(feature = "default"))] 4 | pub use release::*; 5 | 6 | #[cfg(feature = "default")] 7 | mod local { 8 | pub const DELAY: u64 = 5; 9 | pub const UPDATE_CHECK: bool = false; 10 | } 11 | 12 | #[cfg(not(feature = "default"))] 13 | mod release { 14 | pub const DELAY: u64 = 1; 15 | pub const UPDATE_CHECK: bool = true; 16 | } 17 | -------------------------------------------------------------------------------- /crates/core/src/utils.rs: -------------------------------------------------------------------------------- 1 | pub fn fmt_duration(d: std::time::Duration) -> String { 2 | let millis = d.as_millis(); 3 | 4 | let mut duration = String::new(); 5 | if millis > 1000 { 6 | duration.push_str(&format!("{}s ", millis / 1000)); 7 | } 8 | let millis = millis % 1000; 9 | if millis > 0 { 10 | duration.push_str(&format!("{}ms", millis)); 11 | } 12 | 13 | duration 14 | } 15 | -------------------------------------------------------------------------------- /crates/cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cli" 3 | version.workspace = true 4 | description.workspace = true 5 | edition.workspace = true 6 | 7 | [dependencies] 8 | core.workspace = true 9 | tokio.workspace = true 10 | clap.workspace = true 11 | anyhow.workspace = true 12 | humansize.workspace = true 13 | serde_json.workspace = true 14 | reqwest.workspace = true 15 | hcl-rs.workspace = true 16 | colored_json = "5.0" 17 | hex = "0.4" 18 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # TypeScript cache 5 | \*.tsbuildinfo 6 | 7 | # Optional npm cache directory 8 | .npm 9 | 10 | # Optional eslint cache 11 | .eslintcache 12 | 13 | # Optional REPL history 14 | .node_repl_history 15 | 16 | \*.tgz 17 | 18 | .temp 19 | .cache 20 | 21 | .serverless/ 22 | 23 | # Stores VSCode versions used for testing VSCode extensions 24 | .dev.vars 25 | .wrangler/ 26 | -------------------------------------------------------------------------------- /src/app/panels/http/panes/response/idle.rs: -------------------------------------------------------------------------------- 1 | use crate::app::panels::http::panes::response::ResponsePaneMsg; 2 | use iced::widget::{Column, container}; 3 | 4 | use crate::components::{icon, icons}; 5 | 6 | pub fn view<'a>() -> iced::Element<'a, ResponsePaneMsg> { 7 | Column::new() 8 | .push(container(icon(icons::SendUp).size(80.0)).padding(10)) 9 | .push(iced::widget::Text::new("Send Request to view response.")) 10 | .align_x(iced::Alignment::Center) 11 | .into() 12 | } 13 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "lib": ["es2021"], 5 | "module": "es2022", 6 | "moduleResolution": "node", 7 | "types": ["@cloudflare/workers-types/2023-07-01"], 8 | "resolveJsonModule": true, 9 | "noEmit": true, 10 | 11 | "isolatedModules": true, 12 | 13 | "forceConsistentCasingInFileNames": true, 14 | 15 | /* Type Checking */ 16 | "strict": true, 17 | "noImplicitAny": true, 18 | "strictNullChecks": true, 19 | 20 | "skipLibCheck": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/panels/http/panes/request/body_editor.rs: -------------------------------------------------------------------------------- 1 | use crate::components::{ContentType, code_editor, editor::Content}; 2 | 3 | use crate::app::panels::http::panes::request::RequestPaneMsg; 4 | use iced::Element; 5 | use iced::widget::container; 6 | 7 | pub fn view(content: &Content, content_type: ContentType) -> Element { 8 | container( 9 | code_editor(content, content_type) 10 | .editable() 11 | .map(RequestPaneMsg::BodyEditorAction), 12 | ) 13 | .height(iced::Length::Fill) 14 | .width(iced::Length::Fill) 15 | .into() 16 | } 17 | -------------------------------------------------------------------------------- /src/components/editor.rs: -------------------------------------------------------------------------------- 1 | mod content; 2 | pub mod highlighters; 3 | mod undo_stack; 4 | mod widget; 5 | use iced_core::text::{self, highlighter}; 6 | 7 | pub use content::{Content, ContentAction}; 8 | pub use widget::{Action, Catalog, Edit, Motion, Status, Style, StyleFn, TextEditor, default}; 9 | 10 | pub fn text_editor<'a, Message, Theme, Renderer>( 11 | content: &'a Content, 12 | ) -> TextEditor<'a, highlighter::PlainText, Message, Theme, Renderer> 13 | where 14 | Message: Clone, 15 | Theme: Catalog + 'a, 16 | Renderer: text::Renderer, 17 | { 18 | TextEditor::new(content) 19 | } 20 | -------------------------------------------------------------------------------- /src/app/panels/http/panes/response/failed.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use crate::app::panels::http::panes::response::ResponsePaneMsg; 4 | use crate::components::{icon, icons}; 5 | use iced::widget::{Column, Row, container, text}; 6 | 7 | pub fn view<'a>(e: Arc) -> iced::Element<'a, ResponsePaneMsg> { 8 | let error_icon = icon(icons::Error).size(60.0); 9 | 10 | let error_msg = Row::new() 11 | .push(text("Error: ")) 12 | .push(text(e.root_cause().to_string())) 13 | .align_y(iced::Alignment::Center); 14 | 15 | Column::new() 16 | .push(container(error_icon).padding(10)) 17 | .push(error_msg) 18 | .align_x(iced::Alignment::Center) 19 | .into() 20 | } 21 | -------------------------------------------------------------------------------- /src/components/modal.rs: -------------------------------------------------------------------------------- 1 | use iced::widget::{Stack, center, container, mouse_area, opaque}; 2 | use iced::{Color, Element}; 3 | 4 | pub fn modal<'a, Message: Clone + 'a>( 5 | base: impl Into>, 6 | content: impl Into>, 7 | on_press: Message, 8 | ) -> Element<'a, Message> { 9 | Stack::with_children([ 10 | base.into(), 11 | opaque( 12 | mouse_area(center(content).style(|_theme| container::Style { 13 | background: Some(Color::BLACK.scale_alpha(0.5).into()), 14 | ..container::Style::default() 15 | })) 16 | .on_press(on_press.clone()) 17 | .on_right_press(on_press), 18 | ), 19 | ]) 20 | .into() 21 | } 22 | -------------------------------------------------------------------------------- /crates/core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "core" 3 | version.workspace = true 4 | edition.workspace = true 5 | description.workspace = true 6 | 7 | [dependencies] 8 | anyhow.workspace = true 9 | chrono.workspace = true 10 | directories.workspace = true 11 | dotenvy.workspace = true 12 | iced.workspace = true 13 | indexmap.workspace = true 14 | jsonwebtoken.workspace = true 15 | log.workspace = true 16 | mime_guess.workspace = true 17 | parsers.workspace = true 18 | regex.workspace = true 19 | reqwest.workspace = true 20 | rquickjs.workspace = true 21 | reqwest_cookie_store.workspace = true 22 | serde.workspace = true 23 | serde_json.workspace = true 24 | serde_with.workspace = true 25 | similar.workspace = true 26 | sqlx.workspace = true 27 | strum.workspace = true 28 | tokio.workspace = true 29 | toml.workspace = true 30 | urlencoding.workspace = true 31 | uuid.workspace = true 32 | -------------------------------------------------------------------------------- /src/components/bordered.rs: -------------------------------------------------------------------------------- 1 | use iced::Element; 2 | use iced::widget::{Column, Row}; 3 | 4 | use crate::components::{horizontal_line, vertical_line}; 5 | 6 | pub fn bordered_left<'a, M: 'a>(width: u16, content: impl Into>) -> Element<'a, M> { 7 | Row::new().push(vertical_line(width)).push(content).into() 8 | } 9 | 10 | pub fn bordered_right<'a, M: 'a>(width: u16, content: impl Into>) -> Element<'a, M> { 11 | Row::new().push(content).push(vertical_line(width)).into() 12 | } 13 | 14 | pub fn bordered_top<'a, M: 'a>(width: u16, content: impl Into>) -> Element<'a, M> { 15 | Column::new() 16 | .push(horizontal_line(width)) 17 | .push(content) 18 | .into() 19 | } 20 | 21 | pub fn bordered_bottom<'a, M: 'a>( 22 | width: u16, 23 | content: impl Into>, 24 | ) -> Element<'a, M> { 25 | Column::new() 26 | .push(content) 27 | .push(horizontal_line(width)) 28 | .into() 29 | } 30 | -------------------------------------------------------------------------------- /crates/core/src/ids.rs: -------------------------------------------------------------------------------- 1 | #[macro_export(local_inner_macros)] 2 | macro_rules! new_id_type { 3 | ( $(#[$outer:meta])* $vis:vis struct $name:ident; $($rest:tt)* ) => { 4 | $(#[$outer])* 5 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, serde::Serialize, serde::Deserialize)] 6 | #[repr(transparent)] 7 | #[serde(transparent)] 8 | $vis struct $name(uuid::Uuid); 9 | 10 | impl $name { 11 | pub const ZERO: Self = Self(uuid::Uuid::nil()); 12 | 13 | pub fn new() -> Self { 14 | Self(uuid::Uuid::new_v4()) 15 | } 16 | 17 | } 18 | 19 | impl std::fmt::Display for $name { 20 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 21 | std::write!(f, "{}", self.0) 22 | } 23 | } 24 | 25 | impl std::default::Default for $name { 26 | fn default() -> Self { 27 | Self::new() 28 | } 29 | } 30 | 31 | $crate::new_id_type!($($rest)*); 32 | }; 33 | () => {} 34 | } 35 | -------------------------------------------------------------------------------- /homebrew/sanchaar.rb: -------------------------------------------------------------------------------- 1 | cask "sanchaar" do 2 | version "0.1.2" 3 | 4 | on_arm do 5 | sha256 "REPLACE_WITH_ARM64_SHA256" 6 | 7 | url "https://github.com/nrjais/sanchaar/releases/download/#{version}/Sanchaar_macOS_aarch64.dmg" 8 | end 9 | on_intel do 10 | sha256 "REPLACE_WITH_X86_64_SHA256" 11 | 12 | url "https://github.com/nrjais/sanchaar/releases/download/#{version}/Sanchaar_macOS_x64.dmg" 13 | end 14 | 15 | name "Sanchaar" 16 | desc "Fast offline REST API Client" 17 | homepage "https://github.com/nrjais/sanchaar" 18 | 19 | livecheck do 20 | url :homepage 21 | strategy :github_latest 22 | end 23 | 24 | auto_updates true 25 | 26 | app "Sanchaar.app" 27 | 28 | postflight do 29 | system_command "/usr/bin/xattr", 30 | args: ["-cr", "#{appdir}/Sanchaar.app"], 31 | sudo: false 32 | end 33 | 34 | zap trash: [ 35 | "~/Library/Application Support/com.nrjais.sanchaar", 36 | "~/Library/Caches/com.nrjais.sanchaar", 37 | "~/Library/Preferences/com.nrjais.sanchaar.plist", 38 | "~/Library/Saved Application State/com.nrjais.sanchaar.savedState", 39 | ] 40 | end 41 | -------------------------------------------------------------------------------- /src/app/panels/http/panes/response/executing.rs: -------------------------------------------------------------------------------- 1 | use iced::Length; 2 | use iced::widget::{Column, button, center, container, text}; 3 | 4 | use crate::app::panels::http::panes::response::ResponsePaneMsg; 5 | use crate::components::{icon, icons}; 6 | 7 | pub fn center_x<'a>( 8 | el: impl Into>, 9 | padding: u16, 10 | ) -> iced::Element<'a, ResponsePaneMsg> { 11 | container(el) 12 | .height(Length::Shrink) 13 | .padding(padding) 14 | .center_x(Length::Fill) 15 | .into() 16 | } 17 | 18 | pub fn view<'a>() -> iced::Element<'a, ResponsePaneMsg> { 19 | let cancel = center_x( 20 | button(container(text("Cancel").size(16.0)).padding([0, 24])) 21 | .style(button::danger) 22 | .on_press(ResponsePaneMsg::CancelRequest), 23 | 0, 24 | ); 25 | 26 | let col = Column::new() 27 | .push(icon(icons::DotsCircle).size(40)) 28 | .push(text("Executing Request.")) 29 | .push(cancel) 30 | .spacing(8) 31 | .align_x(iced::Alignment::Center) 32 | .height(Length::Shrink) 33 | .width(Length::Shrink); 34 | 35 | center(col).into() 36 | } 37 | -------------------------------------------------------------------------------- /src/app/panels/http/panes/request/bulk_edit.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashSet, sync::Arc}; 2 | 3 | use crate::components::scrollable; 4 | use iced::widget::container; 5 | 6 | use crate::{ 7 | components::{ContentType, code_editor, key_value_editor}, 8 | state::request::{BulkEditMsg, BulkEditable}, 9 | }; 10 | 11 | pub fn view( 12 | keys: &BulkEditable, 13 | vars: Arc>, 14 | should_scroll: bool, 15 | ) -> iced::Element { 16 | match keys { 17 | BulkEditable::KeyValue(keys) => { 18 | let editor = key_value_editor(keys, &vars).on_change(BulkEditMsg::KeyValue); 19 | if should_scroll { 20 | scrollable(editor) 21 | .height(iced::Length::Shrink) 22 | .width(iced::Length::Shrink) 23 | .into() 24 | } else { 25 | editor 26 | } 27 | } 28 | BulkEditable::Editor(content) => container( 29 | code_editor(content, ContentType::Text) 30 | .editable() 31 | .map(BulkEditMsg::Editor), 32 | ) 33 | .style(container::bordered_box) 34 | .height(iced::Length::Fixed(200.)) 35 | .width(iced::Length::Fill) 36 | .into(), 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/components/scrollable.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Element, 3 | border::Radius, 4 | widget::{ 5 | self, Scrollable, 6 | scrollable::{self, Scrollbar}, 7 | }, 8 | }; 9 | 10 | pub enum Direction { 11 | Vertical, 12 | Horizontal, 13 | Both, 14 | } 15 | 16 | pub fn scrollable<'a, Message>(base: impl Into>) -> Scrollable<'a, Message> { 17 | scrollable_with(base, Direction::Vertical) 18 | } 19 | 20 | pub fn scrollable_with<'a, Message>( 21 | base: impl Into>, 22 | direction: Direction, 23 | ) -> Scrollable<'a, Message> { 24 | let scrollbar: Scrollbar = Scrollbar::default().spacing(0).width(8).scroller_width(8); 25 | 26 | let direction = match direction { 27 | Direction::Vertical => scrollable::Direction::Vertical(scrollbar), 28 | Direction::Horizontal => scrollable::Direction::Horizontal(scrollbar), 29 | Direction::Both => scrollable::Direction::Both { 30 | vertical: scrollbar, 31 | horizontal: scrollbar, 32 | }, 33 | }; 34 | 35 | widget::scrollable(base) 36 | .direction(direction) 37 | .style(|theme, status| { 38 | let mut style = scrollable::default(theme, status); 39 | style.horizontal_rail.scroller.border.radius = Radius::new(100); 40 | style.vertical_rail.scroller.border.radius = Radius::new(100); 41 | 42 | style 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /src/components/mod.rs: -------------------------------------------------------------------------------- 1 | mod bordered; 2 | mod button_tabs; 3 | mod card_tabs; 4 | mod code_editor; 5 | mod context_menu; 6 | mod helpers; 7 | mod icon; 8 | mod key_value_editor; 9 | mod key_value_viewer; 10 | mod line_editor; 11 | mod lines; 12 | mod min_dimension; 13 | mod modal; 14 | mod multi_file_picker; 15 | mod script_view; 16 | 17 | pub mod colors; 18 | pub mod editor; 19 | mod scrollable; 20 | pub mod split; 21 | 22 | pub use bordered::{bordered_bottom, bordered_left, bordered_right, bordered_top}; 23 | pub use button_tabs::{ButtonTab, button_tab, button_tabs, vertical_button_tabs}; 24 | pub use card_tabs::{CardTab, TabBarAction, card_tab, card_tabs}; 25 | pub use code_editor::{CodeEditor, CodeEditorMsg, ContentType, code_editor}; 26 | pub use context_menu::{context_menu, menu_item}; 27 | pub use helpers::*; 28 | pub use icon::{NerdIcon, icon, icon_button, icons}; 29 | pub use key_value_editor::{KeyValList, KeyValUpdateMsg, KeyValue, key_value_editor}; 30 | pub use key_value_viewer::key_value_viewer; 31 | pub use line_editor::{LineEditor, LineEditorMsg, line_editor}; 32 | pub use lines::{horizontal_line, vertical_line}; 33 | pub use min_dimension::{MinDimension, min_height, min_width}; 34 | pub use modal::modal; 35 | pub use multi_file_picker::{ 36 | FilePickerAction, FilePickerUpdateMsg, KeyFile, KeyFileList, multi_file_picker, 37 | }; 38 | pub use script_view::{ 39 | ScriptViewConfig, script_editor, script_list_view, script_placeholder, script_selector, 40 | }; 41 | pub use scrollable::{Direction, scrollable, scrollable_with}; 42 | -------------------------------------------------------------------------------- /src/commands/dialog.rs: -------------------------------------------------------------------------------- 1 | use std::{convert::identity, sync::Arc}; 2 | 3 | use iced::Task; 4 | use iced::futures::FutureExt; 5 | use rfd::{AsyncFileDialog, FileHandle}; 6 | 7 | pub fn open_folder_dialog(title: &str) -> Task>> { 8 | Task::perform( 9 | AsyncFileDialog::new() 10 | .set_title(title) 11 | .set_can_create_directories(true) 12 | .pick_folder() 13 | .map(|res| res.map(Arc::new)), 14 | identity, 15 | ) 16 | } 17 | 18 | pub fn open_file_dialog(title: &str) -> Task> { 19 | Task::perform( 20 | AsyncFileDialog::new() 21 | .set_title(title) 22 | .set_can_create_directories(true) 23 | .pick_file(), 24 | identity, 25 | ) 26 | } 27 | 28 | pub fn open_file_dialog_with_filter( 29 | title: &str, 30 | extensions: &'static [&'static str], 31 | ) -> Task>> { 32 | Task::perform( 33 | AsyncFileDialog::new() 34 | .set_title(title) 35 | .add_filter("Files", extensions) 36 | .pick_file() 37 | .map(|res| res.map(Arc::new)), 38 | identity, 39 | ) 40 | } 41 | 42 | pub fn create_file_dialog(title: &str) -> Task>> { 43 | Task::perform( 44 | AsyncFileDialog::new() 45 | .set_title(title) 46 | .set_can_create_directories(true) 47 | .save_file() 48 | .map(|res| res.map(Arc::new)), 49 | identity, 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /crates/cli/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod color; 2 | pub mod run; 3 | pub mod test; 4 | 5 | use clap::{Parser, Subcommand}; 6 | use std::path::PathBuf; 7 | 8 | use clap::{arg, command}; 9 | 10 | #[derive(Parser, Debug)] 11 | #[command(version, about, long_about = None)] 12 | #[command(name = "sanchaar")] 13 | struct Cli { 14 | /// Path to collection, defaults to current directory 15 | #[arg(short, long, value_name = "PATH", default_value = ".")] 16 | path: PathBuf, 17 | 18 | #[command(subcommand)] 19 | command: Commands, 20 | } 21 | 22 | #[derive(Debug, Subcommand)] 23 | enum Commands { 24 | /// Run a request file 25 | #[command(arg_required_else_help = true)] 26 | Run { 27 | /// Path to request file 28 | request: PathBuf, 29 | 30 | /// Run in verbose mode 31 | /// If not provided, only body is printed 32 | /// If provided, status, headers, duration, and size are also printed 33 | #[arg(short, long)] 34 | verbose: bool, 35 | }, 36 | /// Run a request file 37 | Test { 38 | /// Path to test specific file or directory 39 | /// If not provided, all tests are run 40 | #[arg(value_name = "PATH")] 41 | path: Option, 42 | }, 43 | } 44 | 45 | #[tokio::main] 46 | pub async fn main() -> anyhow::Result<()> { 47 | let cli = Cli::parse(); 48 | 49 | match cli.command { 50 | Commands::Run { request, verbose } => run::run(cli.path, request, verbose).await, 51 | Commands::Test { path } => test::test(cli.path, path.unwrap_or_default()).await, 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/components/key_value_viewer.rs: -------------------------------------------------------------------------------- 1 | use crate::components::scrollable; 2 | use iced::widget::{container, table, text_input}; 3 | use iced::{Background, Border, Theme}; 4 | use iced::{Element, Length}; 5 | 6 | use crate::components::bold; 7 | 8 | pub fn key_value_viewer<'a, M: Clone + 'a>( 9 | values: impl IntoIterator, 10 | ) -> Element<'a, M> { 11 | let text_view = |v: &str| { 12 | text_input("", v) 13 | .size(16) 14 | .width(Length::FillPortion(3)) 15 | .padding(0) 16 | .style(|t: &Theme, _s| { 17 | let palette = t.extended_palette(); 18 | text_input::Style { 19 | background: Background::Color(palette.background.base.color), 20 | border: Border::default(), 21 | icon: palette.background.weak.text, 22 | placeholder: palette.secondary.base.color, 23 | value: palette.background.base.text, 24 | selection: palette.primary.weak.color, 25 | } 26 | }) 27 | }; 28 | 29 | let columns = [ 30 | table::column(bold("Name"), |(key, _): (&str, &str)| text_view(key)) 31 | .width(Length::FillPortion(1)), 32 | table::column(bold("Value"), |(_, val): (&str, &str)| text_view(val)) 33 | .width(Length::FillPortion(2)), 34 | ]; 35 | 36 | scrollable( 37 | container(table(columns, values)).style(|t: &Theme| container::Style { 38 | border: container::bordered_box(t).border, 39 | ..container::transparent(t) 40 | }), 41 | ) 42 | .into() 43 | } 44 | -------------------------------------------------------------------------------- /src/app/popups/update_confirmation.rs: -------------------------------------------------------------------------------- 1 | use iced::widget::{Column, text}; 2 | use iced::{Element, Task}; 3 | use iced_auto_updater_plugin::{AutoUpdaterInput, ReleaseInfo}; 4 | use lib::APP_VERSION; 5 | use std::borrow::Cow; 6 | 7 | use crate::app::AppMsg; 8 | use crate::state::AppState; 9 | use crate::state::popups::{Popup, UpdateConfirmationState}; 10 | 11 | use super::PopupMsg; 12 | 13 | #[derive(Debug, Clone)] 14 | pub enum Message { 15 | Confirm(ReleaseInfo), 16 | } 17 | 18 | impl Message { 19 | pub fn update(self, state: &mut AppState) -> Task { 20 | match self { 21 | Self::Confirm(release) => { 22 | Popup::close(&mut state.common); 23 | 24 | let msg = state 25 | .plugins 26 | .auto_updater 27 | .input(AutoUpdaterInput::DownloadAndInstall(release.clone())); 28 | state.queue.push(AppMsg::Plugin(msg)); 29 | 30 | Task::none() 31 | } 32 | } 33 | } 34 | } 35 | 36 | pub fn title() -> Cow<'static, str> { 37 | Cow::Borrowed("Update Available") 38 | } 39 | 40 | pub fn view<'a>(popup_state: &'a UpdateConfirmationState) -> Element<'a, Message> { 41 | let version = &popup_state.0.tag_name; 42 | Column::new() 43 | .push(text("New update available to install!".to_string()).size(16)) 44 | .push(text(format!("Updated version: {}, ", version)).size(12)) 45 | .push(text(format!("Current version: {}", APP_VERSION)).size(12)) 46 | .spacing(8) 47 | .width(400) 48 | .into() 49 | } 50 | 51 | pub fn done(popup_state: &UpdateConfirmationState) -> Option { 52 | Some(Message::Confirm(popup_state.0.clone())) 53 | } 54 | -------------------------------------------------------------------------------- /src/app/panels/http/panes/response/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::state::{AppState, HttpTab, Tab, response::ResponseState}; 2 | use iced::{Element, Task, widget::center}; 3 | 4 | mod completed; 5 | mod executing; 6 | mod failed; 7 | mod idle; 8 | 9 | #[derive(Debug, Clone)] 10 | pub enum ResponsePaneMsg { 11 | Completed(completed::CompletedMsg), 12 | CancelRequest, 13 | } 14 | 15 | impl ResponsePaneMsg { 16 | pub fn update(self, state: &mut AppState) -> Task { 17 | let active_tab = state.active_tab; 18 | let Some(Tab::Http(tab)) = state.tabs.get_mut(&active_tab) else { 19 | return Task::none(); 20 | }; 21 | match self { 22 | Self::Completed(msg) => msg.update(tab).map(ResponsePaneMsg::Completed), 23 | Self::CancelRequest => { 24 | let res_state = &tab.response.state; 25 | let is_executing = matches!(res_state, ResponseState::Executing); 26 | if let Some(Tab::Http(tab)) = state.get_tab_mut(active_tab) 27 | && is_executing 28 | { 29 | tab.cancel_tasks(); 30 | } 31 | 32 | Task::none() 33 | } 34 | } 35 | } 36 | } 37 | 38 | pub fn view(tab: &HttpTab) -> Element { 39 | let res = &tab.response; 40 | 41 | let res = match res.state { 42 | ResponseState::Idle => idle::view(), 43 | ResponseState::Executing => executing::view(), 44 | ResponseState::Completed(ref result) => { 45 | completed::view(tab, result).map(ResponsePaneMsg::Completed) 46 | } 47 | ResponseState::Failed(ref e) => failed::view(e.clone()), 48 | }; 49 | 50 | center(res).padding([4, 0]).into() 51 | } 52 | -------------------------------------------------------------------------------- /src/app/panels/mod.rs: -------------------------------------------------------------------------------- 1 | use iced::Task; 2 | use iced::widget::container; 3 | 4 | use crate::state::{AppState, Tab}; 5 | 6 | pub mod collection; 7 | pub mod cookie_store; 8 | pub mod history; 9 | pub mod http; 10 | pub mod perf; 11 | 12 | #[derive(Debug, Clone)] 13 | pub enum PanelMsg { 14 | Http(http::HttpTabMsg), 15 | Collection(collection::CollectionTabMsg), 16 | Cookies(cookie_store::CookieTabMsg), 17 | History(history::HistoryTabMsg), 18 | Perf(perf::PerfTabMsg), 19 | } 20 | 21 | impl PanelMsg { 22 | pub fn update(self, state: &mut AppState) -> Task { 23 | match self { 24 | PanelMsg::Http(msg) => msg.update(state).map(PanelMsg::Http), 25 | PanelMsg::Collection(msg) => msg.update(state).map(PanelMsg::Collection), 26 | PanelMsg::Cookies(msg) => msg.update(state).map(PanelMsg::Cookies), 27 | PanelMsg::History(msg) => msg.update(state).map(PanelMsg::History), 28 | PanelMsg::Perf(msg) => msg.update(state).map(PanelMsg::Perf), 29 | } 30 | } 31 | } 32 | 33 | pub fn view<'a>(state: &'a AppState, tab: &'a Tab) -> iced::Element<'a, PanelMsg> { 34 | let req = match tab { 35 | Tab::Http(tab) => http::view(state, tab).map(PanelMsg::Http), 36 | Tab::Collection(tab) => { 37 | let col = state.common.collections.get(tab.collection_key).unwrap(); 38 | collection::view(tab, col).map(PanelMsg::Collection) 39 | } 40 | Tab::CookieStore(tab) => cookie_store::view(tab).map(PanelMsg::Cookies), 41 | Tab::History(tab) => history::view(state, tab).map(PanelMsg::History), 42 | Tab::Perf(tab) => perf::view(state, tab).map(PanelMsg::Perf), 43 | }; 44 | 45 | container::Container::new(req) 46 | .width(iced::Length::Fill) 47 | .height(iced::Length::Fill) 48 | .into() 49 | } 50 | -------------------------------------------------------------------------------- /src/app/panels/perf/mod.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use iced::padding; 4 | use iced::{ 5 | Element, Length, Task, 6 | widget::{Column, container}, 7 | }; 8 | 9 | use crate::components::split::vertical_split; 10 | use crate::state::{AppState, tabs::perf_tab::PerfTab}; 11 | 12 | pub mod config_pane; 13 | pub mod report_pane; 14 | 15 | #[derive(Debug, Clone)] 16 | pub enum PerfTabMsg { 17 | Config(Box), 18 | Report(report_pane::ReportMsg), 19 | SplitResize(f32), 20 | } 21 | 22 | impl PerfTabMsg { 23 | pub fn update(self, state: &mut AppState) -> Task { 24 | match self { 25 | PerfTabMsg::Config(msg) => msg 26 | .update(state) 27 | .map(|msg| PerfTabMsg::Config(Box::new(msg))), 28 | PerfTabMsg::Report(msg) => msg.update(state).map(PerfTabMsg::Report), 29 | PerfTabMsg::SplitResize(ratio) => { 30 | let Some(crate::state::Tab::Perf(tab)) = state.active_tab_mut() else { 31 | return Task::none(); 32 | }; 33 | tab.set_split_at(ratio); 34 | Task::none() 35 | } 36 | } 37 | } 38 | } 39 | 40 | pub fn view<'a>(state: &'a AppState, tab: &'a PerfTab) -> Element<'a, PerfTabMsg> { 41 | let config_view = config_pane::view(state, tab).map(|msg| PerfTabMsg::Config(Box::new(msg))); 42 | let report_view = report_pane::view(state, tab).map(PerfTabMsg::Report); 43 | 44 | let panes = vertical_split( 45 | config_view, 46 | report_view, 47 | tab.split_at, 48 | PerfTabMsg::SplitResize, 49 | ) 50 | .direction(state.split_direction) 51 | .focus_delay(Duration::from_millis(50)) 52 | .handle_width(8.); 53 | 54 | let content = container(panes).padding(padding::top(4)); 55 | 56 | Column::new() 57 | .push(content) 58 | .height(Length::Fill) 59 | .width(Length::Fill) 60 | .spacing(4) 61 | .into() 62 | } 63 | -------------------------------------------------------------------------------- /src/state/tabs/collection_tab.rs: -------------------------------------------------------------------------------- 1 | use lib::http::{CollectionKey, collection::Collection}; 2 | use std::time::Duration; 3 | 4 | use crate::components::KeyValList; 5 | 6 | use crate::components::editor; 7 | use crate::state::environment::EnvironmentsEditor; 8 | use crate::state::{environment::environment_keyvals, utils::from_core_kv_list}; 9 | 10 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] 11 | pub enum CollectionTabId { 12 | #[default] 13 | Settings, 14 | Environments, 15 | Scripts, 16 | } 17 | 18 | #[derive(Debug)] 19 | pub struct CollectionTab { 20 | pub name: String, 21 | pub default_env: Option, 22 | pub collection_key: CollectionKey, 23 | pub tab: CollectionTabId, 24 | pub env_editor: EnvironmentsEditor, 25 | pub headers: KeyValList, 26 | pub disable_ssl: bool, 27 | pub timeout: Duration, 28 | pub timeout_str: String, 29 | pub edited: bool, 30 | pub selected_script: Option, 31 | pub script_content: editor::Content, 32 | pub script_edited: bool, 33 | } 34 | 35 | impl CollectionTab { 36 | pub fn new(key: CollectionKey, col: &Collection) -> Self { 37 | let default_env = col 38 | .default_env 39 | .as_ref() 40 | .and_then(|env| col.environments.get(*env)) 41 | .map(|env| env.name.clone()); 42 | 43 | CollectionTab { 44 | name: col.name.clone(), 45 | tab: CollectionTabId::Settings, 46 | default_env, 47 | collection_key: key, 48 | headers: from_core_kv_list(&col.headers, false), 49 | env_editor: environment_keyvals(&col.environments), 50 | disable_ssl: col.disable_ssl, 51 | timeout: col.timeout, 52 | timeout_str: col.timeout.as_millis().to_string(), 53 | edited: false, 54 | selected_script: None, 55 | script_content: editor::Content::new(), 56 | script_edited: false, 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(mismatched_lifetime_syntaxes)] 2 | 3 | pub mod app; 4 | pub mod commands; 5 | pub mod components; 6 | mod debug; 7 | pub mod hotkeys; 8 | pub mod ids; 9 | pub mod state; 10 | pub mod subscription; 11 | 12 | use iced::{ 13 | Size, Task, 14 | window::{Position, Settings}, 15 | }; 16 | use iced_window_state_plugin::{AppName, WindowState, WindowStatePlugin}; 17 | use lib::APP_NAME; 18 | use state::AppState; 19 | use std::borrow::Cow; 20 | use tokio::runtime::Runtime; 21 | 22 | use crate::{app::AppMsg, state::install_plugins}; 23 | 24 | const HACK_REG_BYTES: &[u8] = include_bytes!("../fonts/HackNerdFont-Regular.ttf"); 25 | 26 | fn main() { 27 | env_logger::init(); 28 | match app() { 29 | Ok(_) => (), 30 | Err(e) => { 31 | log::error!("{e}"); 32 | std::process::exit(1); 33 | } 34 | }; 35 | } 36 | 37 | fn load_window_state() -> Option { 38 | let app_name = AppName::new("com", "nrjais", APP_NAME); 39 | let rt = Runtime::new().unwrap(); 40 | 41 | rt.block_on(WindowStatePlugin::load(&app_name)) 42 | } 43 | 44 | pub fn app() -> Result<(), iced::Error> { 45 | let window_state = load_window_state(); 46 | let maximized = window_state.is_none(); 47 | let window_state = window_state.unwrap_or_default(); 48 | 49 | let state_init = { 50 | move || { 51 | let (plugins, task) = install_plugins(); 52 | ( 53 | AppState::new(plugins), 54 | Task::batch([task.map(AppMsg::Plugin), commands::init_command()]), 55 | ) 56 | } 57 | }; 58 | 59 | iced::application(state_init, app::update, app::view) 60 | .theme(AppState::theme) 61 | .antialiasing(true) 62 | .subscription(subscription::all) 63 | .font(Cow::from(HACK_REG_BYTES)) 64 | .window(Settings { 65 | size: window_state.size, 66 | position: Position::Specific(window_state.position), 67 | maximized, 68 | min_size: Some(Size::new(900.0, 600.0)), 69 | ..Default::default() 70 | }) 71 | .title(APP_NAME) 72 | .run() 73 | } 74 | -------------------------------------------------------------------------------- /src/app/popups/app_settings.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use crate::components::{button_tab, button_tabs}; 4 | use iced::widget::{Column, Row, pick_list, space, text}; 5 | use iced::{Element, Task, Theme}; 6 | 7 | use crate::state::AppState; 8 | use crate::state::popups::{AppSettingTabs, AppSettingsState, Popup}; 9 | 10 | #[derive(Debug, Clone)] 11 | pub enum Message { 12 | TabChange(AppSettingTabs), 13 | ChangeTheme(Theme), 14 | Done, 15 | } 16 | 17 | impl Message { 18 | pub fn update(self, state: &mut AppState) -> Task { 19 | let Some(Popup::AppSettings(data)) = state.common.popup.as_mut() else { 20 | return Task::none(); 21 | }; 22 | 23 | match self { 24 | Message::Done => { 25 | state.common.popup = None; 26 | } 27 | Message::TabChange(tab) => { 28 | data.active_tab = tab; 29 | } 30 | Message::ChangeTheme(theme) => { 31 | state.theme = theme; 32 | } 33 | } 34 | Task::none() 35 | } 36 | } 37 | 38 | pub fn title<'a>() -> Cow<'a, str> { 39 | Cow::Borrowed("Settings") 40 | } 41 | 42 | pub fn done(_data: &AppSettingsState) -> Option { 43 | Some(Message::Done) 44 | } 45 | 46 | pub(crate) fn view<'a>(state: &'a AppState, data: &'a AppSettingsState) -> Element<'a, Message> { 47 | let tab_bar = button_tabs( 48 | data.active_tab, 49 | [button_tab(AppSettingTabs::General, move || text("General"))].into_iter(), 50 | Message::TabChange, 51 | None, 52 | ); 53 | let content = match data.active_tab { 54 | AppSettingTabs::General => general_tab(state), 55 | }; 56 | 57 | Column::new() 58 | .push(tab_bar) 59 | .push(content) 60 | .spacing(16) 61 | .width(400) 62 | .into() 63 | } 64 | 65 | fn general_tab(state: &AppState) -> Element { 66 | let size = 14; 67 | let theme = Row::new() 68 | .push(text("Theme")) 69 | .push(space::horizontal()) 70 | .push(pick_list(Theme::ALL, Some(&state.theme), Message::ChangeTheme).text_size(size)) 71 | .align_y(iced::Alignment::Center); 72 | 73 | Column::new().push(theme).spacing(8).into() 74 | } 75 | -------------------------------------------------------------------------------- /src/app/panels/collection/env_table.rs: -------------------------------------------------------------------------------- 1 | use lib::http::Collection; 2 | 3 | use iced::{ 4 | Element, Length, 5 | widget::{container, table, text}, 6 | }; 7 | 8 | use crate::{ 9 | app::panels::collection::env_editor::Message, 10 | components::{bold, line_editor}, 11 | state::{environment::EnvVariable, tabs::collection_tab::CollectionTab}, 12 | }; 13 | 14 | pub fn view<'a>(tab: &'a CollectionTab, col: &'a Collection) -> Element<'a, Message> { 15 | let editor = &tab.env_editor; 16 | let vars = col.dotenv_env_chain().all_var_set(); 17 | 18 | if editor.variables.is_empty() && editor.environments.is_empty() { 19 | return container(text("No variables found, add new variable/environments")).into(); 20 | } 21 | 22 | let mut columns = vec![ 23 | table::column(bold("Key"), |(index, env): (usize, &EnvVariable)| { 24 | container( 25 | line_editor(&env.name) 26 | .highlight(false) 27 | .placeholder("Name") 28 | .map(move |msg| Message::UpdateVarName(index, msg)), 29 | ) 30 | .width(Length::Fixed(150.)) 31 | }) 32 | .width(Length::Fixed(150.)), 33 | ]; 34 | columns.extend(editor.environments.iter().map(|(env_key, env)| { 35 | let vars = vars.clone(); 36 | table::column( 37 | bold(env.name.as_str()), 38 | move |(index, env): (usize, &EnvVariable)| -> Element<'a, Message> { 39 | let var = env.values.get(env_key); 40 | if let Some(var) = var { 41 | container( 42 | line_editor(var) 43 | .vars(vars.clone()) 44 | .editable() 45 | .map(move |msg| Message::UpdateVarValue(index, *env_key, msg)), 46 | ) 47 | .width(Length::Fixed(200.)) 48 | .into() 49 | } else { 50 | text("").into() 51 | } 52 | }, 53 | ) 54 | .width(Length::Fixed(200.)) 55 | })); 56 | 57 | container(table(columns, editor.variables.iter().enumerate())) 58 | .style(container::bordered_box) 59 | .height(Length::Shrink) 60 | .width(Length::Shrink) 61 | .into() 62 | } 63 | -------------------------------------------------------------------------------- /crates/core/src/persistence/mod.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::{ops::Not, path::PathBuf}; 3 | use strum::{Display, EnumString}; 4 | 5 | use crate::http::{KeyValList, KeyValue}; 6 | 7 | pub mod collections; 8 | pub mod environment; 9 | pub mod history; 10 | pub mod request; 11 | 12 | pub const TOML_SUFFIX: &str = "toml"; 13 | pub const TOML_EXTENSION: &str = ".toml"; 14 | pub const JS_EXTENSION: &str = "js"; 15 | pub const TS_EXTENSION: &str = "ts"; 16 | pub const COLLECTION_ROOT_FILE: &str = "collection.toml"; 17 | pub const ENVIRONMENTS: &str = "environments"; 18 | pub const SCRIPTS: &str = "scripts"; 19 | pub const REQUESTS: &str = "requests"; 20 | pub const HISTORY_DB: &str = "history.db"; 21 | 22 | #[derive(Debug, Serialize, Deserialize)] 23 | pub struct EncodedKeyValue { 24 | pub name: String, 25 | pub value: String, 26 | #[serde(default, skip_serializing_if = "Not::not")] 27 | pub disabled: bool, 28 | } 29 | 30 | #[derive(Debug, Serialize, Deserialize)] 31 | pub struct EncodedKeyFile { 32 | pub name: String, 33 | #[serde(default, skip_serializing_if = "Option::is_none")] 34 | pub path: Option, 35 | #[serde(default, skip_serializing_if = "Not::not")] 36 | pub disabled: bool, 37 | } 38 | 39 | impl From for EncodedKeyValue { 40 | fn from(value: KeyValue) -> Self { 41 | EncodedKeyValue { 42 | name: value.name, 43 | value: value.value, 44 | disabled: value.disabled, 45 | } 46 | } 47 | } 48 | 49 | impl From for KeyValue { 50 | fn from(value: EncodedKeyValue) -> Self { 51 | KeyValue { 52 | name: value.name, 53 | value: value.value, 54 | disabled: value.disabled, 55 | } 56 | } 57 | } 58 | 59 | #[derive(Debug, Clone, Copy, Serialize, Default, Deserialize, Display, EnumString)] 60 | pub enum Version { 61 | #[default] 62 | V1, 63 | } 64 | 65 | pub fn encode_key_values(kv: KeyValList) -> Vec { 66 | kv.into_iter().map(|v| v.into()).collect() 67 | } 68 | 69 | pub fn decode_key_values(kv: Vec) -> KeyValList { 70 | let mut list = Vec::new(); 71 | for v in kv { 72 | list.push(KeyValue { 73 | name: v.name, 74 | value: v.value, 75 | disabled: v.disabled, 76 | }); 77 | } 78 | 79 | KeyValList::from(list) 80 | } 81 | -------------------------------------------------------------------------------- /src/app/panels/http/mod.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use iced::padding; 4 | use iced::widget::Column; 5 | use iced::{Element, Task, widget::container}; 6 | 7 | use crate::components::split::vertical_split; 8 | use crate::state::{AppState, HttpTab, Tab}; 9 | 10 | use self::panes::{request, response}; 11 | 12 | pub mod action_bar; 13 | pub mod panes; 14 | pub mod url_bar; 15 | 16 | #[derive(Debug, Clone)] 17 | pub enum HttpTabMsg { 18 | Req(request::RequestPaneMsg), 19 | Res(response::ResponsePaneMsg), 20 | Url(url_bar::UrlBarMsg), 21 | Actions(action_bar::ActionBarMsg), 22 | SplitResize(f32), 23 | } 24 | 25 | impl HttpTabMsg { 26 | pub fn update(self, state: &mut AppState) -> Task { 27 | match self { 28 | HttpTabMsg::Req(msg) => msg.update(state).map(HttpTabMsg::Req), 29 | HttpTabMsg::Res(msg) => msg.update(state).map(HttpTabMsg::Res), 30 | HttpTabMsg::Url(msg) => msg.update(state).map(HttpTabMsg::Url), 31 | HttpTabMsg::Actions(ac) => ac.update(state).map(HttpTabMsg::Actions), 32 | HttpTabMsg::SplitResize(ratio) => { 33 | let Some(Tab::Http(tab)) = state.active_tab_mut() else { 34 | return Task::none(); 35 | }; 36 | tab.set_split_at(ratio); 37 | Task::none() 38 | } 39 | } 40 | } 41 | } 42 | 43 | pub fn view<'a>(state: &'a AppState, tab: &'a HttpTab) -> Element<'a, HttpTabMsg> { 44 | let col = state.common.collections.get(tab.collection_key()); 45 | 46 | let url_bar = url_bar::view(tab, col).map(HttpTabMsg::Url); 47 | let action_bar = col.map(|col| action_bar::view(tab, col).map(HttpTabMsg::Actions)); 48 | 49 | let request_view = request::view(tab, col).map(HttpTabMsg::Req); 50 | let response_view = response::view(tab).map(HttpTabMsg::Res); 51 | let panes = vertical_split( 52 | request_view, 53 | response_view, 54 | tab.split_at, 55 | HttpTabMsg::SplitResize, 56 | ) 57 | .direction(state.split_direction) 58 | .focus_delay(Duration::from_millis(50)) 59 | .handle_width(8.); 60 | 61 | let req_res = container(panes).padding(padding::top(4)); 62 | Column::new() 63 | .push(action_bar) 64 | .push(url_bar) 65 | .push(req_res) 66 | .height(iced::Length::Fill) 67 | .width(iced::Length::Fill) 68 | .spacing(4) 69 | .into() 70 | } 71 | -------------------------------------------------------------------------------- /src/app/panels/collection/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod env_editor; 2 | pub mod env_table; 3 | mod scripts; 4 | mod settings; 5 | 6 | use lib::http::collection::Collection; 7 | 8 | use crate::components::{button_tab, button_tabs}; 9 | use iced::Length; 10 | use iced::widget::{Column, text}; 11 | use iced::{Element, Task}; 12 | 13 | use crate::state::tabs::collection_tab::{CollectionTab, CollectionTabId}; 14 | use crate::state::{AppState, Tab}; 15 | 16 | #[derive(Debug, Clone)] 17 | pub enum CollectionTabMsg { 18 | TabChange(CollectionTabId), 19 | EnvEditor(env_editor::Message), 20 | Settings(settings::Message), 21 | Scripts(scripts::Message), 22 | } 23 | 24 | impl CollectionTabMsg { 25 | pub fn update(self, state: &mut AppState) -> Task { 26 | let Some(Tab::Collection(tab)) = state.active_tab_mut() else { 27 | return Task::none(); 28 | }; 29 | match self { 30 | CollectionTabMsg::TabChange(id) => { 31 | tab.tab = id; 32 | Task::none() 33 | } 34 | CollectionTabMsg::EnvEditor(msg) => msg.update(state).map(CollectionTabMsg::EnvEditor), 35 | CollectionTabMsg::Settings(msg) => msg.update(state).map(CollectionTabMsg::Settings), 36 | CollectionTabMsg::Scripts(msg) => msg.update(state).map(CollectionTabMsg::Scripts), 37 | } 38 | } 39 | } 40 | 41 | pub fn view<'a>(tab: &'a CollectionTab, col: &'a Collection) -> Element<'a, CollectionTabMsg> { 42 | let tab_content = match tab.tab { 43 | CollectionTabId::Environments => { 44 | env_editor::view(tab, col).map(CollectionTabMsg::EnvEditor) 45 | } 46 | CollectionTabId::Settings => settings::view(tab, col).map(CollectionTabMsg::Settings), 47 | CollectionTabId::Scripts => scripts::view(tab, col).map(CollectionTabMsg::Scripts), 48 | }; 49 | 50 | let tabs = button_tabs( 51 | tab.tab, 52 | [ 53 | button_tab(CollectionTabId::Settings, || text("Settings")), 54 | button_tab(CollectionTabId::Environments, || text("Environments")), 55 | button_tab(CollectionTabId::Scripts, || text("Scripts")), 56 | ] 57 | .into_iter(), 58 | CollectionTabMsg::TabChange, 59 | None, 60 | ); 61 | 62 | Column::new() 63 | .push(tabs) 64 | .push(tab_content) 65 | .width(Length::Fill) 66 | .height(Length::Fill) 67 | .spacing(4) 68 | .into() 69 | } 70 | -------------------------------------------------------------------------------- /src/state/tabs/history_tab.rs: -------------------------------------------------------------------------------- 1 | use lib::persistence::history::HistoryEntrySummary; 2 | use std::time::Instant; 3 | 4 | use crate::components::{ 5 | LineEditorMsg, 6 | editor::{self, ContentAction}, 7 | }; 8 | 9 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 10 | pub enum HistoryTabId { 11 | List, 12 | } 13 | 14 | #[derive(Debug)] 15 | pub struct HistoryTab { 16 | pub name: String, 17 | pub tab: HistoryTabId, 18 | pub entries: Vec, 19 | pub error: Option, 20 | pub search_query: editor::Content, 21 | pub is_searching: bool, 22 | pub last_search_input: Option, 23 | pub search_query_text: String, 24 | } 25 | 26 | impl Default for HistoryTab { 27 | fn default() -> Self { 28 | Self::new() 29 | } 30 | } 31 | 32 | impl HistoryTab { 33 | pub fn new() -> Self { 34 | Self { 35 | name: "History".to_string(), 36 | tab: HistoryTabId::List, 37 | entries: Vec::new(), 38 | error: None, 39 | search_query: editor::Content::new(), 40 | is_searching: false, 41 | last_search_input: None, 42 | search_query_text: String::new(), 43 | } 44 | } 45 | 46 | pub fn set_entries(&mut self, entries: Vec) { 47 | self.entries = entries; 48 | self.error = None; 49 | } 50 | 51 | pub fn set_error(&mut self, error: String) { 52 | self.error = Some(error); 53 | } 54 | 55 | pub fn set_search_query(&mut self, msg: LineEditorMsg) { 56 | msg.update(&mut self.search_query); 57 | self.last_search_input = Some(Instant::now()); 58 | self.search_query_text = self.search_query.text().trim().to_string(); 59 | } 60 | 61 | pub fn set_searching(&mut self, searching: bool) { 62 | self.is_searching = searching; 63 | } 64 | 65 | pub fn should_trigger_search(&self) -> bool { 66 | if let Some(last_input) = self.last_search_input { 67 | last_input.elapsed().as_millis() >= 100 68 | } else { 69 | false 70 | } 71 | } 72 | 73 | pub fn clear_search_timer(&mut self) { 74 | self.last_search_input = None; 75 | } 76 | 77 | pub fn clear_search_query(&mut self) { 78 | self.search_query 79 | .perform(ContentAction::Replace("".to_string())); 80 | self.last_search_input = None; 81 | self.is_searching = false; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/state/utils.rs: -------------------------------------------------------------------------------- 1 | use lib::http::{self, KeyFile, KeyValue}; 2 | 3 | use crate::components::{self, KeyFileList, KeyValList}; 4 | 5 | pub fn from_core_kv_list(values: &http::KeyValList, fixed: bool) -> KeyValList { 6 | let values = values 7 | .iter() 8 | .map(|kv| components::KeyValue::new(&kv.name, &kv.value, kv.disabled)) 9 | .collect(); 10 | KeyValList::from(values, fixed) 11 | } 12 | 13 | pub fn to_core_kv_list(list: &KeyValList) -> http::KeyValList { 14 | let vals = list 15 | .values() 16 | .iter() 17 | .map(|kv| KeyValue { 18 | disabled: kv.disabled, 19 | name: kv.name().trim().to_owned(), 20 | value: kv.value().trim().to_owned(), 21 | }) 22 | .filter(|kv| !kv.name.is_empty() || !kv.value.is_empty()) 23 | .collect(); 24 | http::KeyValList::from(vals) 25 | } 26 | 27 | pub fn from_core_kf_list(values: http::KeyFileList) -> KeyFileList { 28 | let values = values 29 | .into_iter() 30 | .map(|kv| components::KeyFile::new(&kv.name, kv.path, kv.disabled)) 31 | .collect(); 32 | KeyFileList::from(values, false) 33 | } 34 | 35 | pub fn to_core_kf_list(list: &KeyFileList) -> http::KeyFileList { 36 | let vals = list 37 | .values() 38 | .iter() 39 | .map(|kv| KeyFile { 40 | disabled: kv.disabled, 41 | name: kv.name().trim().to_owned(), 42 | path: kv.path.to_owned(), 43 | }) 44 | .filter(|kv| !kv.name.is_empty() || kv.path.is_some()) 45 | .collect(); 46 | 47 | http::KeyFileList::from(vals) 48 | } 49 | 50 | pub fn headers_to_string(headers: &reqwest::header::HeaderMap) -> String { 51 | headers 52 | .iter() 53 | .map(|(k, v)| format!("{}: {}", k, v.to_str().unwrap_or_default())) 54 | .collect::>() 55 | .join("\n") 56 | } 57 | 58 | pub fn key_value_to_text(list: &KeyValList) -> String { 59 | list.values() 60 | .iter() 61 | .filter(|kv| !kv.name().is_empty() || !kv.value().is_empty()) 62 | .map(|kv| format!("{}: {}", kv.name(), kv.value())) 63 | .collect::>() 64 | .join("\n") 65 | } 66 | 67 | pub fn key_value_from_text(text: &str) -> KeyValList { 68 | let lines = text.lines(); 69 | let mut key_val_list = Vec::new(); 70 | for line in lines { 71 | let (key, value) = line.split_once(':').unwrap_or_default(); 72 | key_val_list.push(components::KeyValue::new(key.trim(), value.trim(), false)); 73 | } 74 | KeyValList::from(key_val_list, false) 75 | } 76 | -------------------------------------------------------------------------------- /server/echo/index.ts: -------------------------------------------------------------------------------- 1 | type BodyType = "text" | "base64"; 2 | 3 | type RequestConfig = { 4 | method: string; 5 | host: string; 6 | path: string; 7 | headers: Record; 8 | queries: Record; 9 | body: string; 10 | type: BodyType; 11 | }; 12 | 13 | const accessControlHeaders = { 14 | "access-control-allow-origin": "*", 15 | "access-control-allow-methods": "*", 16 | "access-control-allow-headers": "*", 17 | "access-control-expose-headers": "*", 18 | "access-control-max-age": "86400", 19 | "access-control-allow-credentials": "true", 20 | }; 21 | 22 | export default { 23 | async fetch(request: Request): Promise { 24 | if (request.method === "OPTIONS") { 25 | return new Response(null, { 26 | headers: accessControlHeaders, 27 | }); 28 | } 29 | 30 | const requestData = await getRequestBody(request); 31 | return new Response(JSON.stringify(requestData), { 32 | status: 200, 33 | headers: { 34 | "content-type": "application/json", 35 | ...accessControlHeaders, 36 | }, 37 | }); 38 | }, 39 | }; 40 | 41 | const getHeaders = (request: Request): Record => { 42 | const headers: Record = {}; 43 | request.headers.forEach((value, key) => { 44 | headers[key] = value; 45 | }); 46 | return headers; 47 | }; 48 | 49 | const getQueries = (request: Request): Record => { 50 | const queries: Record = {}; 51 | const url = new URL(request.url); 52 | url.searchParams.forEach((value, key) => { 53 | queries[key] = value; 54 | }); 55 | return queries; 56 | }; 57 | 58 | const getBody = async (request: Request): Promise<[string, BodyType]> => { 59 | const body = await request.arrayBuffer(); 60 | const textDecoder = new TextDecoder("utf8", { fatal: true, ignoreBOM: true }); 61 | try { 62 | const text = textDecoder.decode(body); 63 | return [text, "text"]; 64 | } catch { 65 | const base64 = btoa(String.fromCharCode(...new Uint8Array(body))); 66 | return [base64, "base64"]; 67 | } 68 | }; 69 | 70 | const getRequestBody = async (request: Request): Promise => { 71 | const url = new URL(request.url); 72 | 73 | const method = request.method; 74 | const host = url.host; 75 | const path = url.pathname; 76 | 77 | const headers = getHeaders(request); 78 | const queries = getQueries(request); 79 | const [body, type] = await getBody(request); 80 | 81 | return { 82 | method, 83 | host, 84 | path, 85 | queries, 86 | body, 87 | type, 88 | headers, 89 | }; 90 | }; 91 | -------------------------------------------------------------------------------- /crates/cli/src/run.rs: -------------------------------------------------------------------------------- 1 | use colored_json::prelude::ToColoredJson; 2 | use core::{ 3 | client::{create_client, send_request, ContentType, Response}, 4 | http::environment::EnvironmentChain, 5 | persistence::request::read_request, 6 | transformers::request::transform_request, 7 | utils::fmt_duration, 8 | }; 9 | use std::{env, path::PathBuf, sync::Arc}; 10 | 11 | use humansize::{format_size, BINARY}; 12 | 13 | use crate::color::{color, Color}; 14 | 15 | pub async fn run(root: PathBuf, req: PathBuf, verbose: bool) -> anyhow::Result<()> { 16 | let current_dir = env::current_dir()?; 17 | let root = current_dir.join(root); 18 | 19 | let path = root.join(req); 20 | let req = read_request(&path).await?; 21 | 22 | let client = create_client(false, Default::default()); 23 | let req = transform_request(client.clone(), req, EnvironmentChain::new()).await?; 24 | let response = send_request(client, req).await?; 25 | 26 | let Response { 27 | status, 28 | headers, 29 | body, 30 | duration, 31 | size_bytes, 32 | } = response; 33 | if verbose { 34 | println!("{}", color(&status.to_string(), Color::CYAN)); 35 | println!( 36 | "{} {}", 37 | color("Size:", Color::DARKGRAY), 38 | color(&format_size(size_bytes, BINARY), Color::VIOLET) 39 | ); 40 | println!( 41 | "{} {}", 42 | color("Time:", Color::DARKGRAY), 43 | color(&fmt_duration(duration), Color::VIOLET) 44 | ); 45 | 46 | println!(); 47 | if !headers.is_empty() { 48 | println!("{}", color("Headers:", Color::DARKGRAY)); 49 | for (k, v) in headers.iter() { 50 | let value = v.to_str().unwrap_or(""); 51 | println!( 52 | " {}: {}", 53 | color(k.as_str(), Color::BLUE), 54 | color(value, Color::DARKGREEN) 55 | ); 56 | } 57 | } 58 | } 59 | 60 | println!(); 61 | let data = Arc::unwrap_or_clone(body.data.clone()); 62 | match body.content_type { 63 | ContentType::Json => { 64 | let json = String::from_utf8(data)?; 65 | println!("{}", json.to_colored_json_auto()?); 66 | } 67 | ContentType::Text => { 68 | let text = String::from_utf8(data)?; 69 | println!("{}", text); 70 | } 71 | ContentType::Buffer => { 72 | let hex = hex::encode(data); 73 | println!("Hex:\n{}", hex); 74 | } 75 | } 76 | 77 | Ok(()) 78 | } 79 | -------------------------------------------------------------------------------- /src/components/helpers.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use iced::{ 4 | Alignment, Border, Element, Font, Length, Size, Theme, 5 | font::Weight, 6 | widget::{ 7 | self, Container, TextInput, Tooltip, container, responsive, text, tooltip::Position, value, 8 | }, 9 | }; 10 | 11 | pub fn expanded<'a, M>(base: impl Into>) -> Container<'a, M> { 12 | container(base).width(Length::Fill).height(Length::Fill) 13 | } 14 | 15 | pub fn tooltip<'a, M: 'a>(msg: &'a str, base: impl Into>) -> Tooltip<'a, M> { 16 | widget::tooltip( 17 | base, 18 | container(text(msg)) 19 | .style(|theme: &Theme| { 20 | let palette = theme.extended_palette(); 21 | 22 | container::Style { 23 | background: Some(palette.background.weak.color.into()), 24 | border: Border { 25 | width: 1.0, 26 | radius: 4.0.into(), 27 | color: palette.background.strong.color, 28 | }, 29 | ..Default::default() 30 | } 31 | }) 32 | .padding([2, 4]), 33 | Position::FollowCursor, 34 | ) 35 | .delay(Duration::from_millis(800)) 36 | } 37 | 38 | pub fn text_input<'a, M: Clone + 'a>( 39 | placeholder: &str, 40 | value: &str, 41 | on_change: impl Fn(String) -> M + 'a + Clone, 42 | ) -> TextInput<'a, M> { 43 | iced::widget::text_input(placeholder, value) 44 | .on_input(on_change.clone()) 45 | .on_paste(on_change) 46 | } 47 | 48 | pub fn bold<'a, M: 'a>(txt: &'a str) -> Element<'a, M> { 49 | text(txt) 50 | .font(Font { 51 | weight: Weight::Bold, 52 | ..Font::DEFAULT 53 | }) 54 | .into() 55 | } 56 | 57 | pub fn ellipsis<'a, M: 'a>(txt: &'a str, multiple: f32, size: f32) -> Element<'a, M> { 58 | responsive(move |s: Size| -> Element<'a, M> { 59 | let txt_width = txt.len() as f32 * multiple; 60 | if s.width > txt_width { 61 | text(txt) 62 | .size(size) 63 | .align_y(Alignment::Center) 64 | .height(Length::Shrink) 65 | .into() 66 | } else { 67 | let max_chars = (s.width / multiple).min(txt.len() as f32) - 2. * multiple; 68 | value(format!("...{}", &txt[txt.len() - max_chars as usize..])) 69 | .size(size) 70 | .align_y(Alignment::Center) 71 | .height(Length::Shrink) 72 | .into() 73 | } 74 | }) 75 | .into() 76 | } 77 | -------------------------------------------------------------------------------- /src/state/tabs/perf_tab.rs: -------------------------------------------------------------------------------- 1 | use iced::task::Handle; 2 | use lib::http::CollectionRequest; 3 | use lib::perf::{PerfConfig, PerfMetrics, PerfStats}; 4 | 5 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 6 | pub enum PerfState { 7 | Idle, 8 | Running, 9 | Completed, 10 | Failed, 11 | Cancelled, 12 | } 13 | 14 | #[derive(Debug)] 15 | pub struct PerfTab { 16 | pub request: Option, 17 | pub config: PerfConfig, 18 | pub state: PerfState, 19 | pub metrics: Option, 20 | pub stats: Option, 21 | pub progress: u64, 22 | pub split_at: f32, 23 | pub cancel: Option, 24 | } 25 | 26 | impl PerfTab { 27 | pub fn new() -> Self { 28 | Self { 29 | request: None, 30 | config: PerfConfig::default(), 31 | state: PerfState::Idle, 32 | metrics: None, 33 | stats: None, 34 | progress: 0, 35 | split_at: 0.45, 36 | cancel: None, 37 | } 38 | } 39 | 40 | pub fn set_split_at(&mut self, at: f32) { 41 | self.split_at = at.clamp(0.25, 0.70); 42 | } 43 | 44 | pub fn set_request(&mut self, request: CollectionRequest) { 45 | self.request = Some(request); 46 | } 47 | 48 | pub fn start_test(&mut self) { 49 | self.reset(); 50 | self.state = PerfState::Running; 51 | } 52 | 53 | pub fn update_progress(&mut self, metrics: PerfMetrics) { 54 | self.progress = metrics.total_requests; 55 | let stats = metrics.calculate_stats(); 56 | self.metrics = Some(metrics); 57 | self.stats = Some(stats); 58 | } 59 | 60 | pub fn complete_test(&mut self, metrics: PerfMetrics) { 61 | let stats = metrics.calculate_stats(); 62 | self.metrics = Some(metrics); 63 | self.stats = Some(stats); 64 | self.state = PerfState::Completed; 65 | } 66 | 67 | pub fn fail_test(&mut self) { 68 | self.state = PerfState::Failed; 69 | } 70 | 71 | pub fn cancel_test(&mut self) { 72 | self.cancel_tasks(); 73 | self.state = PerfState::Cancelled; 74 | } 75 | 76 | pub fn reset(&mut self) { 77 | self.cancel.take(); 78 | self.state = PerfState::Idle; 79 | self.progress = 0; 80 | self.metrics = None; 81 | self.stats = None; 82 | } 83 | 84 | pub fn cancel_tasks(&mut self) { 85 | self.cancel.take(); 86 | } 87 | 88 | pub fn add_task(&mut self, task: Handle) { 89 | self.cancel = Some(task.abort_on_drop()); 90 | } 91 | } 92 | 93 | impl Default for PerfTab { 94 | fn default() -> Self { 95 | Self::new() 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/app/mod.rs: -------------------------------------------------------------------------------- 1 | use iced::Task; 2 | use iced_auto_updater_plugin::AutoUpdaterOutput; 3 | use iced_plugins::PluginMessage; 4 | 5 | use crate::components::modal; 6 | use crate::state::UpdateStatus; 7 | use popups::PopupMsg; 8 | 9 | use crate::app::content_section::MainPageMsg; 10 | use crate::{commands, hotkeys}; 11 | use crate::{commands::TaskMsg, state::AppState}; 12 | 13 | pub mod bottom_bar; 14 | mod collection_tree; 15 | mod content_section; 16 | mod panels; 17 | mod popups; 18 | 19 | #[derive(Debug, Clone)] 20 | pub enum AppMsg { 21 | Command(TaskMsg), 22 | MainPage(MainPageMsg), 23 | Popup(PopupMsg), 24 | Subscription(hotkeys::Message), 25 | Plugin(PluginMessage), 26 | AutoUpdater(AutoUpdaterOutput), 27 | } 28 | 29 | pub fn update(state: &mut AppState, msg: AppMsg) -> Task { 30 | let cmd = match msg { 31 | AppMsg::Command(msg) => msg.update(state).map(AppMsg::Command), 32 | AppMsg::MainPage(msg) => msg.update(state).map(AppMsg::MainPage), 33 | AppMsg::Popup(msg) => msg.update(state).map(AppMsg::Popup), 34 | AppMsg::Subscription(msg) => msg.update(state).map(AppMsg::Subscription), 35 | AppMsg::Plugin(msg) => state.plugins.manager.update(msg).map(AppMsg::Plugin), 36 | AppMsg::AutoUpdater(msg) => handle_auto_updater(state, msg).map(AppMsg::Plugin), 37 | }; 38 | Task::batch([ 39 | cmd, 40 | commands::background(state).map(AppMsg::Command), 41 | state.queue.task(), 42 | ]) 43 | } 44 | 45 | pub fn view(state: &AppState) -> iced::Element { 46 | let main_page = content_section::view(state).map(AppMsg::MainPage); 47 | 48 | if let Some(ref popup) = state.common.popup { 49 | let popup = popups::view(state, popup).map(AppMsg::Popup); 50 | modal(main_page, popup, AppMsg::Popup(PopupMsg::Ignore)) 51 | } else { 52 | main_page 53 | } 54 | } 55 | 56 | fn handle_auto_updater(state: &mut AppState, msg: AutoUpdaterOutput) -> Task { 57 | match msg { 58 | AutoUpdaterOutput::UpdateAvailable(release) => { 59 | state.update_status = UpdateStatus::Available(release); 60 | } 61 | AutoUpdaterOutput::DownloadStarted(_) => { 62 | state.update_status = UpdateStatus::Downloading; 63 | } 64 | AutoUpdaterOutput::InstallationStarted => { 65 | state.update_status = UpdateStatus::Installing; 66 | } 67 | AutoUpdaterOutput::Error(msg) => { 68 | log::error!("Auto updater error: {:?}", msg); 69 | state.update_status = UpdateStatus::None; 70 | } 71 | AutoUpdaterOutput::InstallationCompleted => { 72 | state.update_status = UpdateStatus::Completed; 73 | } 74 | _ => {} 75 | } 76 | Task::none() 77 | } 78 | -------------------------------------------------------------------------------- /src/components/code_editor.rs: -------------------------------------------------------------------------------- 1 | use iced::{Element, Font, Length, border, highlighter}; 2 | use iced_core::text::Wrapping; 3 | 4 | use crate::components::editor::{self, ContentAction, Status, text_editor}; 5 | 6 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 7 | pub enum ContentType { 8 | Json, 9 | Text, 10 | XML, 11 | HTML, 12 | JS, 13 | } 14 | 15 | pub struct CodeEditor<'a> { 16 | pub code: &'a editor::Content, 17 | pub content_type: ContentType, 18 | pub editable: bool, 19 | } 20 | 21 | impl<'a> CodeEditor<'a> { 22 | pub fn editable(mut self) -> Self { 23 | self.editable = true; 24 | self 25 | } 26 | 27 | pub fn view(self) -> Element<'a, CodeEditorMsg> { 28 | text_editor(self.code) 29 | .height(Length::Fill) 30 | .font(Font::MONOSPACE) 31 | .wrapping(Wrapping::WordOrGlyph) 32 | .on_action(move |ac| CodeEditorMsg::EditorAction(ac, self.editable)) 33 | .highlight( 34 | self.content_type.to_extension(), 35 | highlighter::Theme::SolarizedDark, 36 | ) 37 | .style(|theme: &iced::Theme, status| editor::Style { 38 | border: match status { 39 | Status::Focused { .. } => border::width(1) 40 | .rounded(2) 41 | .color(theme.extended_palette().primary.strong.color), 42 | _ => border::width(1) 43 | .rounded(2) 44 | .color(theme.extended_palette().background.weak.color), 45 | }, 46 | ..editor::default(theme, status) 47 | }) 48 | .into() 49 | } 50 | 51 | pub fn map(self, f: impl Fn(CodeEditorMsg) -> M + 'a) -> Element<'a, M> { 52 | self.view().map(f) 53 | } 54 | } 55 | 56 | impl ContentType { 57 | pub fn to_extension(&self) -> &'static str { 58 | match self { 59 | ContentType::Json => "json", 60 | ContentType::Text => "txt", 61 | ContentType::XML => "xml", 62 | ContentType::HTML => "html", 63 | ContentType::JS => "js", 64 | } 65 | } 66 | } 67 | 68 | #[derive(Debug, Clone)] 69 | pub enum CodeEditorMsg { 70 | EditorAction(ContentAction, bool), 71 | } 72 | 73 | impl CodeEditorMsg { 74 | pub fn update(self, state: &mut editor::Content) { 75 | match self { 76 | Self::EditorAction(action, editable) => { 77 | if editable || !action.is_edit() { 78 | state.perform(action); 79 | } 80 | } 81 | } 82 | } 83 | } 84 | 85 | pub fn code_editor<'a>(code: &'a editor::Content, content_type: ContentType) -> CodeEditor<'a> { 86 | CodeEditor { 87 | code, 88 | content_type, 89 | editable: false, 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /crates/core/src/persistence/environment.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::path::{Path, PathBuf}; 3 | 4 | use serde::{Deserialize, Serialize}; 5 | use tokio::fs; 6 | 7 | use crate::http::environment::{Environment, Environments}; 8 | 9 | use super::ENVIRONMENTS; 10 | use super::{TOML_EXTENSION, Version}; 11 | 12 | #[derive(Debug, Deserialize, Serialize)] 13 | pub struct EncodedEnvironment { 14 | pub name: String, 15 | pub version: Version, 16 | #[serde(default)] 17 | pub variables: HashMap, 18 | } 19 | 20 | impl From for Environment { 21 | fn from(val: EncodedEnvironment) -> Self { 22 | Environment { 23 | name: val.name, 24 | variables: val.variables.into(), 25 | } 26 | } 27 | } 28 | 29 | impl From for EncodedEnvironment { 30 | fn from(environment: Environment) -> Self { 31 | EncodedEnvironment { 32 | name: environment.name, 33 | version: Version::V1, 34 | variables: HashMap::clone(&environment.variables), 35 | } 36 | } 37 | } 38 | 39 | pub async fn read_environments(col: &Path) -> anyhow::Result { 40 | let env_path = col.join(ENVIRONMENTS); 41 | let exists = fs::try_exists(&env_path).await?; 42 | if !exists { 43 | return Ok(Environments::new()); 44 | } 45 | 46 | let mut files = fs::read_dir(env_path).await?; 47 | 48 | let mut environments = Environments::new(); 49 | 50 | while let Some(file) = files.next_entry().await? { 51 | if !file.file_type().await?.is_file() { 52 | continue; 53 | } 54 | 55 | let name = file.file_name(); 56 | let name = name.to_string_lossy(); 57 | 58 | let without_ext = name.trim_end_matches(&TOML_EXTENSION); 59 | if !name.ends_with(&TOML_EXTENSION) || without_ext.is_empty() { 60 | continue; 61 | } 62 | 63 | let content = fs::read_to_string(&file.path()).await?; 64 | 65 | let environment: EncodedEnvironment = toml::from_str(&content)?; 66 | 67 | environments.insert(environment.into()); 68 | } 69 | 70 | Ok(environments) 71 | } 72 | 73 | pub fn encode_environments(environment: &Environments) -> Vec { 74 | environment 75 | .entries() 76 | .map(|(_, env)| EncodedEnvironment::from(env.clone())) 77 | .collect() 78 | } 79 | 80 | pub async fn save_environments( 81 | path: PathBuf, 82 | environments: Vec, 83 | ) -> anyhow::Result<()> { 84 | let env_path = path.join(ENVIRONMENTS); 85 | 86 | fs::create_dir_all(&env_path).await?; 87 | 88 | for environment in environments.iter() { 89 | let path = env_path.join(format!("{}{}", &environment.name, TOML_EXTENSION)); 90 | let content = toml::to_string_pretty(environment)?; 91 | 92 | fs::write(&path, content).await?; 93 | } 94 | 95 | Ok(()) 96 | } 97 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | name: Test 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, macos-latest, windows-latest] 19 | 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | 24 | - name: Install Rust 25 | uses: dtolnay/rust-toolchain@stable 26 | with: 27 | toolchain: stable 28 | components: rustfmt, clippy 29 | 30 | - name: Install linux dependencies 31 | if: runner.os == 'Linux' 32 | run: | 33 | sudo apt-get update 34 | sudo apt-get install -y \ 35 | libssl-dev \ 36 | libxcb-shape0-dev \ 37 | libxcb-xfixes0-dev \ 38 | libxkbcommon-dev \ 39 | pkg-config \ 40 | gcc \ 41 | clang \ 42 | mold 43 | 44 | - uses: Swatinem/rust-cache@v2 45 | 46 | - name: Check formatting 47 | run: cargo fmt --all -- --check 48 | 49 | - name: Clippy 50 | run: cargo clippy --all-targets --all-features -- -D warnings 51 | 52 | - name: Build 53 | run: cargo build --verbose 54 | 55 | - name: Run tests 56 | run: cargo test --verbose 57 | 58 | build-check: 59 | name: Build Check (${{ matrix.target }}) 60 | runs-on: ${{ matrix.os }} 61 | strategy: 62 | matrix: 63 | include: 64 | - os: ubuntu-latest 65 | target: x86_64-unknown-linux-gnu 66 | - os: ubuntu-24.04-arm 67 | target: aarch64-unknown-linux-gnu 68 | - os: macos-latest 69 | target: x86_64-apple-darwin 70 | - os: macos-latest 71 | target: aarch64-apple-darwin 72 | 73 | steps: 74 | - name: Checkout 75 | uses: actions/checkout@v4 76 | 77 | - name: Install Rust 78 | uses: dtolnay/rust-toolchain@stable 79 | with: 80 | toolchain: stable 81 | targets: ${{ matrix.target }} 82 | - uses: rui314/setup-mold@v1 83 | if: runner.os == 'Linux' 84 | 85 | - name: Install linux dependencies 86 | if: runner.os == 'Linux' 87 | run: | 88 | sudo apt-get update 89 | sudo apt-get install -y \ 90 | libssl-dev \ 91 | libxcb-shape0-dev \ 92 | libxcb-xfixes0-dev \ 93 | libxkbcommon-dev \ 94 | pkg-config \ 95 | gcc 96 | 97 | - uses: Swatinem/rust-cache@v2 98 | with: 99 | key: ${{ matrix.os }}-${{ matrix.target }}-v2 100 | cache-all-crates: false 101 | save-if: ${{ github.ref == 'refs/heads/main' }} 102 | 103 | - name: Build 104 | run: | 105 | cargo build --release --target ${{ matrix.target }} --no-default-features 106 | -------------------------------------------------------------------------------- /src/components/icon.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use iced::{ 4 | Alignment::Center, 5 | Font, Length, Renderer, Theme, 6 | widget::{Button, Text, button, container, text}, 7 | }; 8 | 9 | pub fn icon<'a>(icon: NerdIcon) -> Text<'a, Theme, Renderer> { 10 | text(icon.0) 11 | .align_x(Center) 12 | .align_y(Center) 13 | .font(Font::with_name("Hack Nerd Font")) 14 | } 15 | 16 | pub fn icon_button<'a, M: 'a>( 17 | ico: NerdIcon, 18 | size: Option, 19 | padding: Option, 20 | ) -> Button<'a, M> { 21 | let ico = match size { 22 | Some(size) => icon(ico).size(size), 23 | None => icon(ico), 24 | }; 25 | 26 | button(container(ico).padding(padding.map(|h| [0, h]).unwrap_or([0, 0]))) 27 | .padding(0) 28 | .width(Length::Shrink) 29 | } 30 | 31 | pub struct NerdIcon(pub char); 32 | 33 | impl Display for NerdIcon { 34 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 35 | write!(f, "{}", self.0) 36 | } 37 | } 38 | 39 | #[allow(dead_code, non_upper_case_globals)] 40 | pub mod icons { 41 | use super::NerdIcon; 42 | 43 | pub const TriangleRight: NerdIcon = NerdIcon(''); 44 | pub const Cookie: NerdIcon = NerdIcon('󰆘'); 45 | pub const TriangleDown: NerdIcon = NerdIcon(''); 46 | pub const CloseBox: NerdIcon = NerdIcon('󰅗'); 47 | pub const PlusBox: NerdIcon = NerdIcon('󰐖'); 48 | pub const Plus: NerdIcon = NerdIcon('󰐕'); 49 | pub const Delete: NerdIcon = NerdIcon('󰆴'); 50 | pub const CheckBold: NerdIcon = NerdIcon('󰸞'); 51 | pub const Wand: NerdIcon = NerdIcon(''); 52 | pub const Pencil: NerdIcon = NerdIcon('󰏫'); 53 | pub const Gear: NerdIcon = NerdIcon('󰒓'); 54 | pub const Import: NerdIcon = NerdIcon('󰋺'); 55 | pub const Download: NerdIcon = NerdIcon(''); 56 | pub const Copy: NerdIcon = NerdIcon(''); 57 | pub const Filter: NerdIcon = NerdIcon(''); 58 | pub const FileCancel: NerdIcon = NerdIcon('󰷆'); 59 | pub const Error: NerdIcon = NerdIcon(''); 60 | pub const Edit: NerdIcon = NerdIcon('󰷉'); 61 | pub const EditLines: NerdIcon = NerdIcon('󱩽'); 62 | pub const Send: NerdIcon = NerdIcon('󰒊'); 63 | pub const Replay: NerdIcon = NerdIcon('󰑙'); 64 | pub const SendUp: NerdIcon = NerdIcon(''); 65 | pub const Path: NerdIcon = NerdIcon(''); 66 | pub const ContentSave: NerdIcon = NerdIcon('󰆓'); 67 | pub const DotsCircle: NerdIcon = NerdIcon('󱥸'); 68 | pub const Dot: NerdIcon = NerdIcon('•'); 69 | pub const Close: NerdIcon = NerdIcon('󰅖'); 70 | pub const API: NerdIcon = NerdIcon('󰖟'); 71 | pub const Folder: NerdIcon = NerdIcon('󰉋'); 72 | pub const FolderOpen: NerdIcon = NerdIcon('󰝰'); 73 | pub const History: NerdIcon = NerdIcon('󰋚'); 74 | pub const SplitVertical: NerdIcon = NerdIcon(''); 75 | pub const SplitHorizontal: NerdIcon = NerdIcon(''); 76 | pub const OpenSideBar: NerdIcon = NerdIcon(''); 77 | pub const CloseSideBar: NerdIcon = NerdIcon(''); 78 | pub const Speedometer: NerdIcon = NerdIcon('󰓅'); 79 | } 80 | -------------------------------------------------------------------------------- /src/commands/perf.rs: -------------------------------------------------------------------------------- 1 | use iced::Task; 2 | use iced::task::{Straw, sipper}; 3 | use lib::http::EnvironmentChain; 4 | use std::path::PathBuf; 5 | use tokio::sync::mpsc; 6 | 7 | use crate::state::{AppState, Tab}; 8 | use lib::perf::{PerfConfig, PerfMetrics, PerfRunner}; 9 | use lib::persistence::request::read_request; 10 | 11 | #[derive(Debug, Clone)] 12 | pub enum PerfResult { 13 | Progress(PerfMetrics), 14 | Completed(Result), 15 | } 16 | 17 | #[derive(Debug, Clone)] 18 | pub enum BenchmarkError { 19 | Error(String), 20 | Cancelled, 21 | } 22 | 23 | pub fn benchmark( 24 | request_path: PathBuf, 25 | client: reqwest::Client, 26 | config: PerfConfig, 27 | env_chain: EnvironmentChain, 28 | ) -> impl Straw { 29 | sipper(move |mut progress| async move { 30 | let request = match read_request(&request_path).await { 31 | Ok(req) => req, 32 | Err(e) => { 33 | return Err(BenchmarkError::Error(format!( 34 | "Failed to load request: {}", 35 | e 36 | ))); 37 | } 38 | }; 39 | 40 | let runner = PerfRunner::new(client, config); 41 | 42 | let (sender, mut receiver) = mpsc::channel(100); 43 | let handle = tokio::spawn(async move { 44 | while let Some(metrics) = receiver.recv().await { 45 | let _ = progress.send(metrics).await; 46 | } 47 | }); 48 | 49 | let result = runner.run(request, env_chain, sender).await; 50 | 51 | handle.abort(); 52 | 53 | match result { 54 | Ok(metrics) => Ok(metrics), 55 | Err(e) => Err(BenchmarkError::Error(e.to_string())), 56 | } 57 | }) 58 | } 59 | 60 | pub fn start_benchmark(state: &mut AppState) -> Task { 61 | let collection_request = match state.active_tab_mut() { 62 | Some(Tab::Perf(tab)) => tab.request, 63 | _ => return Task::none(), 64 | }; 65 | 66 | let Some(collection_request) = collection_request else { 67 | return Task::none(); 68 | }; 69 | 70 | let Some(collection) = state.common.collections.get(collection_request.0) else { 71 | return Task::done(PerfResult::Completed(Err(BenchmarkError::Error( 72 | "Collection not found".to_string(), 73 | )))); 74 | }; 75 | 76 | let Some(request_ref) = state.common.collections.get_ref(collection_request) else { 77 | return Task::done(PerfResult::Completed(Err(BenchmarkError::Error( 78 | "Request not found".to_string(), 79 | )))); 80 | }; 81 | 82 | let request_path = request_ref.path.clone(); 83 | let env_chain = collection.env_chain(); 84 | let disable_ssl = collection.disable_ssl; 85 | 86 | let config = match state.active_tab_mut() { 87 | Some(Tab::Perf(tab)) => { 88 | tab.start_test(); 89 | tab.config.clone() 90 | } 91 | _ => return Task::none(), 92 | }; 93 | 94 | let client = if disable_ssl { 95 | state.common.client_no_ssl.clone() 96 | } else { 97 | state.common.client.clone() 98 | }; 99 | 100 | let (task, handle) = Task::sip( 101 | benchmark(request_path, client, config, env_chain), 102 | PerfResult::Progress, 103 | PerfResult::Completed, 104 | ) 105 | .abortable(); 106 | 107 | if let Some(Tab::Perf(tab)) = state.active_tab_mut() { 108 | tab.add_task(handle); 109 | } 110 | 111 | task 112 | } 113 | -------------------------------------------------------------------------------- /src/state/tabs/http_tab.rs: -------------------------------------------------------------------------------- 1 | use iced::task::Handle; 2 | 3 | use crate::commands::builders::ResponseResult; 4 | use crate::state::request::RequestPane; 5 | use crate::state::response::ResponsePane; 6 | use crate::state::response::{CompletedResponse, ResponseState}; 7 | use lib::http::request::Request; 8 | use lib::http::{CollectionKey, CollectionRequest}; 9 | 10 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 11 | pub enum RequestDirtyState { 12 | Clean, 13 | CheckIfDirty, 14 | Dirty, 15 | } 16 | 17 | #[derive(Debug)] 18 | pub struct HttpTab { 19 | pub name: String, 20 | pub collection_ref: CollectionRequest, 21 | request: RequestPane, 22 | pub response: ResponsePane, 23 | pub cancel: Option, 24 | pub editing_name: Option, 25 | pub split_at: f32, 26 | pub request_dirty_state: RequestDirtyState, 27 | } 28 | 29 | impl HttpTab { 30 | pub fn new(name: &str, request: Request, req_ref: CollectionRequest) -> Box { 31 | Box::new(Self { 32 | name: name.to_owned(), 33 | collection_ref: req_ref, 34 | request: RequestPane::from(request), 35 | response: ResponsePane::new(), 36 | cancel: None, 37 | split_at: 0.45, 38 | editing_name: None, 39 | request_dirty_state: RequestDirtyState::Clean, 40 | }) 41 | } 42 | 43 | pub fn new_def() -> Box { 44 | Self::new("Untitled", Default::default(), Default::default()) 45 | } 46 | 47 | pub fn set_split_at(&mut self, at: f32) { 48 | self.split_at = at.clamp(0.25, 0.70); 49 | } 50 | 51 | pub fn from_history( 52 | name: &str, 53 | request: Request, 54 | response: lib::client::Response, 55 | req_ref: CollectionRequest, 56 | ) -> Box { 57 | let mut tab = Self::new(name, request, req_ref); 58 | tab.response.state = ResponseState::Completed(Box::new(CompletedResponse::new(response))); 59 | tab 60 | } 61 | 62 | pub fn is_request_dirty(&self) -> bool { 63 | self.request_dirty_state == RequestDirtyState::Dirty 64 | } 65 | 66 | pub fn request(&self) -> &RequestPane { 67 | &self.request 68 | } 69 | 70 | pub fn request_mut(&mut self) -> &mut RequestPane { 71 | if self.request_dirty_state == RequestDirtyState::Clean { 72 | self.check_dirty(); 73 | } 74 | 75 | &mut self.request 76 | } 77 | 78 | pub fn mark_clean(&mut self) { 79 | self.request_dirty_state = RequestDirtyState::Clean; 80 | } 81 | 82 | pub fn check_dirty(&mut self) { 83 | self.request_dirty_state = RequestDirtyState::CheckIfDirty; 84 | } 85 | 86 | pub fn cancel_tasks(&mut self) { 87 | self.cancel.take(); 88 | self.response.state = ResponseState::Idle; 89 | } 90 | 91 | pub fn add_task(&mut self, task: Handle) { 92 | self.cancel = Some(task.abort_on_drop()); 93 | } 94 | 95 | pub fn collection_key(&self) -> CollectionKey { 96 | self.collection_ref.0 97 | } 98 | 99 | pub fn update_response(&mut self, result: ResponseResult) { 100 | self.cancel_tasks(); 101 | match result { 102 | ResponseResult::Completed(res) => { 103 | self.response.state = 104 | ResponseState::Completed(Box::new(CompletedResponse::new(res))); 105 | } 106 | ResponseResult::Error(e) => { 107 | self.response.state = ResponseState::Failed(e); 108 | } 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/state/tabs/cookies_tab.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use cookie_store::Cookie; 4 | use reqwest_cookie_store::CookieStoreRwLock; 5 | 6 | use crate::components::editor; 7 | use crate::state::CommonState; 8 | 9 | #[derive(Debug)] 10 | pub struct CookiesTab { 11 | pub store: Arc, 12 | pub search_query: editor::Content, 13 | pub search_query_text: String, 14 | pub filtered_cookies: Vec>, 15 | } 16 | 17 | impl CookiesTab { 18 | pub fn new(state: &CommonState) -> Self { 19 | let store = Arc::clone(&state.cookie_store); 20 | let filtered_cookies = Self::get_all_cookies(&store); 21 | Self { 22 | store, 23 | search_query: editor::Content::new(), 24 | search_query_text: String::new(), 25 | filtered_cookies, 26 | } 27 | } 28 | 29 | fn get_all_cookies(store: &Arc) -> Vec> { 30 | store 31 | .read() 32 | .expect("Lock") 33 | .iter_unexpired() 34 | .cloned() 35 | .collect() 36 | } 37 | 38 | pub fn cookies(&self) -> &[Cookie<'static>] { 39 | &self.filtered_cookies 40 | } 41 | 42 | pub fn set_search_query(&mut self, query: &str) { 43 | self.search_query_text = query.to_string(); 44 | self.update_filtered_cookies(); 45 | } 46 | 47 | pub fn clear_search_query(&mut self) { 48 | use crate::components::editor::ContentAction; 49 | self.search_query 50 | .perform(ContentAction::Replace("".to_string())); 51 | self.search_query_text.clear(); 52 | self.update_filtered_cookies(); 53 | } 54 | 55 | fn update_filtered_cookies(&mut self) { 56 | let all_cookies = Self::get_all_cookies(&self.store); 57 | 58 | if self.search_query_text.is_empty() { 59 | self.filtered_cookies = all_cookies; 60 | } else { 61 | let query = self.search_query_text.to_lowercase(); 62 | self.filtered_cookies = all_cookies 63 | .into_iter() 64 | .filter(|cookie| { 65 | cookie.name().to_lowercase().contains(&query) 66 | || cookie.value().to_lowercase().contains(&query) 67 | || cookie 68 | .domain() 69 | .unwrap_or_default() 70 | .to_lowercase() 71 | .contains(&query) 72 | }) 73 | .collect(); 74 | } 75 | } 76 | 77 | pub fn delete_cookie(&mut self, name: &str, domain: &str, path: &str) { 78 | let mut store = self.store.write().expect("Lock"); 79 | let cookies_to_remove: Vec<_> = store 80 | .iter_any() 81 | .filter(|cookie| { 82 | cookie.name() == name 83 | && cookie.domain().unwrap_or_default() == domain 84 | && cookie.path().unwrap_or_default() == path 85 | }) 86 | .cloned() 87 | .collect(); 88 | 89 | for cookie in cookies_to_remove { 90 | store.remove( 91 | cookie.domain().unwrap_or_default(), 92 | cookie.path().unwrap_or_default(), 93 | cookie.name(), 94 | ); 95 | } 96 | drop(store); 97 | self.update_filtered_cookies(); 98 | } 99 | 100 | pub fn clear_all(&mut self) { 101 | let mut store = self.store.write().expect("Lock"); 102 | store.clear(); 103 | drop(store); 104 | self.update_filtered_cookies(); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/app/panels/collection/env_editor.rs: -------------------------------------------------------------------------------- 1 | use iced::widget::{Column, Row, button}; 2 | use iced::{Element, Length, Task}; 3 | 4 | use crate::app::panels::collection::env_table; 5 | use crate::components::Direction; 6 | use crate::components::{LineEditorMsg, scrollable_with}; 7 | use lib::http::collection::Collection; 8 | use lib::http::environment::EnvironmentKey; 9 | 10 | use crate::state::popups::{Popup, PopupNameAction}; 11 | use crate::state::tabs::collection_tab::CollectionTab; 12 | use crate::state::{AppState, Tab}; 13 | 14 | #[derive(Debug, Clone)] 15 | pub enum Message { 16 | DeleteEnv(EnvironmentKey), 17 | CreatNewEnv, 18 | RenameEnv(EnvironmentKey), 19 | AddVariable, 20 | UpdateVarValue(usize, EnvironmentKey, LineEditorMsg), 21 | UpdateVarName(usize, LineEditorMsg), 22 | } 23 | 24 | impl Message { 25 | pub fn update(self, state: &mut AppState) -> Task { 26 | let key = state.active_tab; 27 | let Some(Tab::Collection(tab)) = state.tabs.get_mut(&key) else { 28 | return Task::none(); 29 | }; 30 | let data = &mut tab.env_editor; 31 | 32 | match self { 33 | Message::DeleteEnv(env) => { 34 | data.remove_env(env); 35 | } 36 | Message::CreatNewEnv => { 37 | Popup::popup_name( 38 | &mut state.common, 39 | String::new(), 40 | PopupNameAction::CreateEnvironment(key), 41 | ); 42 | } 43 | Message::RenameEnv(env_key) => { 44 | let name = data 45 | .environments 46 | .get(&env_key) 47 | .map(|env| env.name.clone()) 48 | .unwrap_or_default(); 49 | Popup::popup_name( 50 | &mut state.common, 51 | name, 52 | PopupNameAction::RenameEnvironment(key, env_key), 53 | ); 54 | } 55 | Message::UpdateVarValue(var, env, msg) => { 56 | if let Some(variable) = data.variables.get_mut(var) 57 | && let Some(content) = variable.values.get_mut(&env) 58 | { 59 | msg.update(content); 60 | data.edited = true; 61 | } 62 | } 63 | Message::AddVariable => { 64 | data.add_variable(); 65 | } 66 | Message::UpdateVarName(index, msg) => { 67 | if let Some(variable) = data.variables.get_mut(index) { 68 | msg.update(&mut variable.name); 69 | data.edited = true; 70 | } 71 | } 72 | } 73 | Task::none() 74 | } 75 | } 76 | 77 | pub fn view<'a>(tab: &'a CollectionTab, col: &'a Collection) -> Element<'a, Message> { 78 | let actions = Row::new() 79 | .push( 80 | button("Add Variable") 81 | .padding([2, 4]) 82 | .on_press(Message::AddVariable) 83 | .style(button::secondary), 84 | ) 85 | .push( 86 | button("New Environment") 87 | .padding([2, 4]) 88 | .on_press(Message::CreatNewEnv) 89 | .style(button::secondary), 90 | ) 91 | .spacing(8); 92 | 93 | let editor = scrollable_with(env_table::view(tab, col), Direction::Both).width(Length::Fill); 94 | 95 | Column::new() 96 | .push(actions) 97 | .push(editor) 98 | .spacing(8) 99 | .width(Length::Fill) 100 | .height(Length::Fill) 101 | .padding([8, 0]) 102 | .into() 103 | } 104 | -------------------------------------------------------------------------------- /crates/core/src/perf/runner.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::sync::Arc; 3 | use std::time::{Duration, Instant}; 4 | use tokio::sync::{Mutex, mpsc}; 5 | 6 | use super::metrics::PerfMetrics; 7 | use crate::client::send_request; 8 | use crate::http::environment::EnvironmentChain; 9 | use crate::http::request::Request; 10 | use crate::transformers::request::transform_request; 11 | 12 | #[derive(Debug, Clone, Serialize, Deserialize)] 13 | pub struct PerfConfig { 14 | pub duration: Duration, 15 | pub concurrency: usize, 16 | pub timeout: Duration, 17 | } 18 | 19 | impl Default for PerfConfig { 20 | fn default() -> Self { 21 | Self { 22 | duration: Duration::from_secs(60), 23 | concurrency: 10, 24 | timeout: Duration::from_secs(30), 25 | } 26 | } 27 | } 28 | 29 | pub struct PerfRunner { 30 | client: reqwest::Client, 31 | config: PerfConfig, 32 | } 33 | 34 | impl PerfRunner { 35 | pub fn new(client: reqwest::Client, config: PerfConfig) -> Self { 36 | Self { client, config } 37 | } 38 | 39 | pub async fn run( 40 | &self, 41 | request: Request, 42 | env: EnvironmentChain, 43 | progress: mpsc::Sender, 44 | ) -> anyhow::Result { 45 | let built_request = transform_request(self.client.clone(), request, env).await?; 46 | 47 | if built_request.try_clone().is_none() { 48 | anyhow::bail!("Request with file body not supported for performance testing"); 49 | } 50 | 51 | let metrics = Arc::new(Mutex::new(PerfMetrics::new())); 52 | 53 | let mut tasks = Vec::new(); 54 | let start_time = Instant::now(); 55 | 56 | for _ in 0..self.config.concurrency { 57 | let client = self.client.clone(); 58 | let request = built_request.try_clone().unwrap(); 59 | let metrics = Arc::clone(&metrics); 60 | let timeout = self.config.timeout; 61 | let progress = progress.clone(); 62 | let duration = self.config.duration; 63 | 64 | let task = tokio::spawn(async move { 65 | loop { 66 | let request = request.try_clone().unwrap(); 67 | let metrics = Arc::clone(&metrics); 68 | let client = client.clone(); 69 | 70 | if start_time.elapsed() >= duration { 71 | return; 72 | } 73 | 74 | let result = tokio::time::timeout(timeout, send_request(client, request)).await; 75 | 76 | let mut metrics = metrics.lock().await; 77 | match result { 78 | Ok(Ok(response)) => { 79 | metrics.record_success(response.duration, response.status.as_u16()); 80 | } 81 | Ok(Err(e)) => { 82 | metrics.record_failure(e.to_string()); 83 | } 84 | Err(_) => { 85 | metrics.record_failure("Request timeout".to_string()); 86 | } 87 | } 88 | 89 | let mut snapshot = metrics.clone(); 90 | snapshot.total_duration = start_time.elapsed(); 91 | let _ = progress.send(snapshot).await; 92 | } 93 | }); 94 | 95 | tasks.push(task); 96 | } 97 | 98 | for task in tasks { 99 | let _ = task.await; 100 | } 101 | 102 | let total_duration = start_time.elapsed(); 103 | let mut final_metrics = metrics.lock().await.clone(); 104 | final_metrics.total_duration = total_duration; 105 | 106 | Ok(final_metrics) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/state/environment.rs: -------------------------------------------------------------------------------- 1 | use lib::http::{Environment, EnvironmentKey, environment::Environments}; 2 | use std::{ 3 | collections::{HashMap, HashSet}, 4 | sync::Arc, 5 | }; 6 | 7 | use crate::components::editor; 8 | 9 | #[derive(Debug, Default)] 10 | pub struct EnvVariable { 11 | pub name: editor::Content, 12 | pub values: HashMap, 13 | } 14 | 15 | #[derive(Debug)] 16 | pub struct EnvironmentsEditor { 17 | pub variables: Vec, 18 | pub environments: HashMap, 19 | pub edited: bool, 20 | } 21 | 22 | impl EnvironmentsEditor { 23 | pub fn add_env(&mut self, env: Environment) { 24 | self.environments.insert(EnvironmentKey::new(), env); 25 | self.edited = true; 26 | } 27 | 28 | pub fn remove_env(&mut self, env_key: EnvironmentKey) -> Option { 29 | self.edited = true; 30 | for variable in self.variables.iter_mut() { 31 | variable.values.remove(&env_key); 32 | } 33 | self.environments.remove(&env_key) 34 | } 35 | 36 | pub fn add_variable(&mut self) { 37 | let name = editor::Content::new(); 38 | let values = self 39 | .environments 40 | .keys() 41 | .map(|key| (*key, editor::Content::new())) 42 | .collect(); 43 | 44 | self.variables.push(EnvVariable { name, values }); 45 | self.edited = true; 46 | } 47 | 48 | pub(crate) fn create_env(&mut self, name: String) { 49 | let env = Environment::new(name); 50 | let env_key = EnvironmentKey::new(); 51 | self.environments.insert(env_key, env); 52 | self.edited = true; 53 | for variable in self.variables.iter_mut() { 54 | variable.values.insert(env_key, editor::Content::new()); 55 | } 56 | } 57 | 58 | pub fn get_envs_for_save(&mut self) -> HashMap { 59 | self.edited = false; 60 | let mut envs = HashMap::new(); 61 | for variable in self.variables.iter() { 62 | for (env_key, content) in variable.values.iter() { 63 | let env = envs.entry(*env_key).or_insert_with(HashMap::new); 64 | env.insert(variable.name.text(), content.text()); 65 | } 66 | } 67 | 68 | let envname = |key: &EnvironmentKey| self.environments.get(key).map(|env| env.name.clone()); 69 | 70 | envs.into_iter() 71 | .filter_map(|(key, env)| { 72 | Some(( 73 | key, 74 | Environment { 75 | name: envname(&key)?, 76 | variables: Arc::new(env), 77 | }, 78 | )) 79 | }) 80 | .collect() 81 | } 82 | } 83 | 84 | pub fn environment_keyvals(envs: &Environments) -> EnvironmentsEditor { 85 | let environments: HashMap = envs 86 | .entries() 87 | .map(|(key, env)| (*key, env.clone())) 88 | .collect(); 89 | 90 | let mut variables = envs 91 | .entries() 92 | .flat_map(|(_, env)| env.variables.keys().cloned()) 93 | .collect::>() 94 | .into_iter() 95 | .collect::>(); 96 | variables.sort(); 97 | 98 | let variables = variables 99 | .into_iter() 100 | .map(|name| EnvVariable { 101 | name: editor::Content::with_text(name.as_str()), 102 | values: environments 103 | .iter() 104 | .map(|(key, env)| (*key, env.variables.get(&name).cloned().unwrap_or_default())) 105 | .map(|(key, value)| (key, editor::Content::with_text(value.as_str()))) 106 | .collect(), 107 | }) 108 | .collect(); 109 | 110 | EnvironmentsEditor { 111 | variables, 112 | environments, 113 | edited: false, 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/app/popups/mod.rs: -------------------------------------------------------------------------------- 1 | use iced::Length::{Fill, Shrink}; 2 | use iced::widget::container::Style; 3 | use iced::widget::{Column, Row, button, container, space, text}; 4 | use iced::{Alignment, Element, Task, border}; 5 | 6 | use crate::state::AppState; 7 | use crate::state::popups::Popup; 8 | 9 | mod app_settings; 10 | mod create_collection; 11 | mod name_popup; 12 | mod save_request; 13 | mod update_confirmation; 14 | 15 | #[derive(Clone, Debug)] 16 | pub enum PopupMsg { 17 | CreateCollection(create_collection::Message), 18 | SaveRequest(save_request::Message), 19 | RenamePopup(name_popup::Message), 20 | AppSettings(app_settings::Message), 21 | UpdateConfirmation(update_confirmation::Message), 22 | ClosePopup, 23 | Ignore, 24 | } 25 | 26 | impl PopupMsg { 27 | pub fn update(self, state: &mut AppState) -> Task { 28 | match self { 29 | PopupMsg::CreateCollection(msg) => msg.update(state).map(PopupMsg::CreateCollection), 30 | PopupMsg::SaveRequest(msg) => msg.update(state).map(PopupMsg::SaveRequest), 31 | PopupMsg::RenamePopup(msg) => msg.update(state).map(PopupMsg::RenamePopup), 32 | PopupMsg::AppSettings(msg) => msg.update(state).map(PopupMsg::AppSettings), 33 | PopupMsg::UpdateConfirmation(msg) => msg.update(state), 34 | PopupMsg::ClosePopup => { 35 | Popup::close(&mut state.common); 36 | Task::none() 37 | } 38 | PopupMsg::Ignore => Task::none(), 39 | } 40 | } 41 | } 42 | 43 | pub fn view<'a>(state: &'a AppState, popup: &'a Popup) -> Element<'a, PopupMsg> { 44 | let (title, content, done_msg) = match popup { 45 | Popup::CreateCollection(data) => ( 46 | create_collection::title(), 47 | create_collection::view(state, data).map(PopupMsg::CreateCollection), 48 | create_collection::done(data).map(PopupMsg::CreateCollection), 49 | ), 50 | Popup::SaveRequest(data) => ( 51 | save_request::title(), 52 | save_request::view(state, data).map(PopupMsg::SaveRequest), 53 | save_request::done(data).map(PopupMsg::SaveRequest), 54 | ), 55 | Popup::PopupName(data) => ( 56 | name_popup::title(), 57 | name_popup::view(state, data).map(PopupMsg::RenamePopup), 58 | name_popup::done(data).map(PopupMsg::RenamePopup), 59 | ), 60 | Popup::AppSettings(data) => ( 61 | app_settings::title(), 62 | app_settings::view(state, data).map(PopupMsg::AppSettings), 63 | app_settings::done(data).map(PopupMsg::AppSettings), 64 | ), 65 | Popup::UpdateConfirmation(data) => ( 66 | update_confirmation::title(), 67 | update_confirmation::view(data).map(PopupMsg::UpdateConfirmation), 68 | update_confirmation::done(data).map(PopupMsg::UpdateConfirmation), 69 | ), 70 | }; 71 | 72 | let buttons = Row::new() 73 | .push(space::horizontal()) 74 | .push( 75 | button("Cancel") 76 | .style(button::subtle) 77 | .on_press(PopupMsg::ClosePopup), 78 | ) 79 | .push( 80 | button(popup.done()) 81 | .style(button::primary) 82 | .on_press_maybe(done_msg), 83 | ) 84 | .width(Fill) 85 | .height(Shrink) 86 | .align_y(Alignment::End) 87 | .spacing(8); 88 | 89 | container( 90 | Column::new() 91 | .push(text(title).size(20)) 92 | .push(content) 93 | .push(buttons) 94 | .width(Shrink) 95 | .height(Shrink) 96 | .spacing(12), 97 | ) 98 | .padding(16) 99 | .style(|theme| Style { 100 | background: Some(theme.extended_palette().background.weak.color.into()), 101 | border: border::rounded(6), 102 | ..Style::default() 103 | }) 104 | .into() 105 | } 106 | -------------------------------------------------------------------------------- /crates/core/src/http/request.rs: -------------------------------------------------------------------------------- 1 | use jsonwebtoken::Algorithm; 2 | use serde::{Deserialize, Serialize}; 3 | use std::path::PathBuf; 4 | use strum::{Display, EnumString, VariantArray}; 5 | 6 | use crate::assertions::Assertions; 7 | 8 | use super::{KeyFileList, KeyValList}; 9 | 10 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 11 | pub enum RequestBody { 12 | Multipart { 13 | params: KeyValList, 14 | files: KeyFileList, 15 | }, 16 | Form(KeyValList), 17 | Json(String), 18 | XML(String), 19 | Text(String), 20 | File(Option), 21 | None, 22 | } 23 | 24 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] 25 | #[serde(rename_all = "snake_case")] 26 | pub enum AuthIn { 27 | #[default] 28 | Query, 29 | Header, 30 | } 31 | 32 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] 33 | #[serde(rename_all = "UPPERCASE")] 34 | pub enum JwtAlgorithm { 35 | #[default] 36 | HS256, 37 | HS384, 38 | HS512, 39 | RS256, 40 | RS384, 41 | RS512, 42 | ES256, 43 | ES384, 44 | PS256, 45 | PS384, 46 | PS512, 47 | EdDSA, 48 | } 49 | 50 | impl From<&JwtAlgorithm> for Algorithm { 51 | fn from(val: &JwtAlgorithm) -> Self { 52 | match val { 53 | JwtAlgorithm::HS256 => Algorithm::HS256, 54 | JwtAlgorithm::HS384 => Algorithm::HS384, 55 | JwtAlgorithm::HS512 => Algorithm::HS512, 56 | JwtAlgorithm::RS256 => Algorithm::RS256, 57 | JwtAlgorithm::RS384 => Algorithm::RS384, 58 | JwtAlgorithm::RS512 => Algorithm::RS512, 59 | JwtAlgorithm::ES256 => Algorithm::ES256, 60 | JwtAlgorithm::ES384 => Algorithm::ES384, 61 | JwtAlgorithm::PS256 => Algorithm::PS256, 62 | JwtAlgorithm::PS384 => Algorithm::PS384, 63 | JwtAlgorithm::PS512 => Algorithm::PS512, 64 | JwtAlgorithm::EdDSA => Algorithm::EdDSA, 65 | } 66 | } 67 | } 68 | 69 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 70 | pub enum Auth { 71 | None, 72 | Basic { 73 | username: String, 74 | password: String, 75 | }, 76 | Bearer { 77 | token: String, 78 | }, 79 | APIKey { 80 | key: String, 81 | value: String, 82 | add_to: AuthIn, 83 | }, 84 | JWTBearer { 85 | algorithm: JwtAlgorithm, 86 | secret: String, 87 | payload: String, 88 | add_to: AuthIn, 89 | }, 90 | } 91 | 92 | #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, VariantArray, Display, Default)] 93 | pub enum Method { 94 | #[default] 95 | GET, 96 | POST, 97 | PUT, 98 | DELETE, 99 | PATCH, 100 | HEAD, 101 | OPTIONS, 102 | CONNECT, 103 | TRACE, 104 | } 105 | 106 | #[derive(Debug, Clone, PartialEq)] 107 | pub struct Request { 108 | pub description: String, 109 | pub method: Method, 110 | pub url: String, 111 | pub headers: KeyValList, 112 | pub body: RequestBody, 113 | pub query_params: KeyValList, 114 | pub path_params: KeyValList, 115 | pub auth: Auth, 116 | pub assertions: Assertions, 117 | pub pre_request: Option, 118 | pub post_request: Option, 119 | } 120 | 121 | impl Request { 122 | pub fn extend_headers(&mut self, headers: &KeyValList) { 123 | self.headers.extend(headers.clone()); 124 | } 125 | } 126 | 127 | impl Default for Request { 128 | fn default() -> Self { 129 | Self { 130 | description: "Http request".to_string(), 131 | method: Method::GET, 132 | url: "https://echo.sanchaar.app".to_string(), 133 | headers: KeyValList::new(), 134 | body: RequestBody::None, 135 | query_params: KeyValList::new(), 136 | path_params: KeyValList::new(), 137 | auth: Auth::None, 138 | assertions: Assertions::default(), 139 | pre_request: None, 140 | post_request: None, 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /crates/cli/src/test.rs: -------------------------------------------------------------------------------- 1 | use core::{ 2 | assertions::{self, runner::MatcherResult}, 3 | client::{create_client, send_request}, 4 | http::environment::EnvironmentChain, 5 | persistence::request::read_request, 6 | transformers::request::transform_request, 7 | }; 8 | use std::path::PathBuf; 9 | 10 | use anyhow::Context; 11 | use hcl::Value; 12 | 13 | use crate::color::{color, Color}; 14 | 15 | pub async fn test(root: PathBuf, path: PathBuf) -> anyhow::Result<()> { 16 | let current_dir = std::env::current_dir()?; 17 | let root = current_dir.join(root); 18 | 19 | let path = root.join(path); 20 | 21 | let client = create_client(false, Default::default()); 22 | 23 | let file = tokio::fs::File::open(&path).await?; 24 | if file.metadata().await?.is_dir() { 25 | walk_dir(client, &path).await?; 26 | } else { 27 | test_file(client, &path).await?; 28 | } 29 | 30 | Ok(()) 31 | } 32 | 33 | async fn walk_dir(client: reqwest::Client, path: &PathBuf) -> anyhow::Result<()> { 34 | let mut entries = tokio::fs::read_dir(path).await?; 35 | 36 | while let Some(entry) = entries.next_entry().await? { 37 | let entry_path = entry.path(); 38 | let file = tokio::fs::File::open(&entry_path).await?; 39 | if file.metadata().await?.is_dir() { 40 | Box::pin(walk_dir(client.clone(), &entry_path)).await?; 41 | } else { 42 | test_file(client.clone(), &entry_path).await?; 43 | } 44 | } 45 | 46 | Ok(()) 47 | } 48 | 49 | async fn test_file(client: reqwest::Client, path: &PathBuf) -> anyhow::Result<()> { 50 | let file_name = path 51 | .file_name() 52 | .context("Invalid path")? 53 | .to_str() 54 | .context("Invalid file name")?; 55 | 56 | let req = read_request(path).await?; 57 | 58 | let assertions = req.assertions.clone(); 59 | 60 | let req = transform_request(client.clone(), req, EnvironmentChain::new()).await?; 61 | let response = send_request(client, req).await?; 62 | 63 | let result = assertions::run(&response, &assertions); 64 | 65 | println!("{} - {} assertions", file_name, result.len()); 66 | 67 | let indent = Indent::new(); 68 | for assertion in result { 69 | let indent = indent.inc(); 70 | println!("{:id$}Assert {}", "", assertion.name, id = indent.v); 71 | 72 | for cond in assertion.results { 73 | let indent = indent.inc(); 74 | match cond.result { 75 | MatcherResult::Passed => { 76 | let msg = format!("{:id$}{}", "", cond.name, id = indent.v); 77 | println!("{}", color(&msg, Color::LIGHTGREEN)); 78 | } 79 | MatcherResult::Failed(des) => { 80 | let msg = format!("{:id$}{}", "", cond.name, id = indent.v); 81 | println!("{}", color(&msg, Color::RED)); 82 | { 83 | let indent = indent.inc(); 84 | let msg = format!("{:id$}Summary: {}", "", des.summary, id = indent.v); 85 | println!("{}", color(&msg, Color::YELLOW)); 86 | println!( 87 | "{:id$}Actual: {}", 88 | "", 89 | color(&des.actual.unwrap_or(Value::Null).to_string(), Color::RED), 90 | id = indent.v 91 | ); 92 | println!( 93 | "{:id$}Expected: {}", 94 | "", 95 | color(&des.expected.to_string(), Color::LIGHTGREEN), 96 | id = indent.v 97 | ); 98 | } 99 | } 100 | } 101 | } 102 | } 103 | 104 | Ok(()) 105 | } 106 | 107 | struct Indent { 108 | v: usize, 109 | } 110 | 111 | impl Indent { 112 | fn new() -> Self { 113 | Self { v: 0 } 114 | } 115 | 116 | fn inc(&self) -> Self { 117 | Self { v: self.v + 2 } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/state/response.rs: -------------------------------------------------------------------------------- 1 | use lib::client; 2 | use std::sync::Arc; 3 | 4 | use crate::components::editor::{self, Content}; 5 | use jsonpath_rust::JsonPath; 6 | use serde_json::Value; 7 | 8 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] 9 | pub enum ResponseTabId { 10 | #[default] 11 | BodyPreview, 12 | BodyRaw, 13 | Headers, 14 | } 15 | 16 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 17 | pub enum BodyMode { 18 | Pretty, 19 | Raw, 20 | } 21 | 22 | #[derive(Debug)] 23 | pub struct CompletedResponse { 24 | pub result: client::Response, 25 | pub content: Option, 26 | pub raw: editor::Content, 27 | pub filtered_content: Option, 28 | pub json_path_filter: Option, 29 | pub value: Option, 30 | } 31 | 32 | impl CompletedResponse { 33 | pub fn selected_content(&self, mode: BodyMode) -> &editor::Content { 34 | if let Some(filtered_content) = &self.filtered_content { 35 | return filtered_content; 36 | } 37 | 38 | match mode { 39 | BodyMode::Pretty => self.content.as_ref().unwrap_or(&self.raw), 40 | BodyMode::Raw => &self.raw, 41 | } 42 | } 43 | 44 | pub fn selected_content_mut(&mut self, mode: BodyMode) -> &mut editor::Content { 45 | if let Some(filtered_content) = &mut self.filtered_content { 46 | return filtered_content; 47 | } 48 | 49 | match mode { 50 | BodyMode::Pretty => self.content.as_mut().unwrap_or(&mut self.raw), 51 | BodyMode::Raw => &mut self.raw, 52 | } 53 | } 54 | 55 | pub fn apply_json_path_filter(&mut self) { 56 | self.filtered_content = None; 57 | let Some(json_path_filter) = self.json_path_filter.as_ref() else { 58 | return; 59 | }; 60 | let filter = json_path_filter.text().trim().to_string(); 61 | if filter.is_empty() { 62 | return; 63 | } 64 | 65 | let filtered = self.value.as_ref().and_then(|json| { 66 | let filtered = json.query(&filter).ok()?; 67 | if filtered.len() == 1 { 68 | serde_json::to_string_pretty(&filtered[0]).ok() 69 | } else { 70 | serde_json::to_string_pretty(&filtered).ok() 71 | } 72 | }); 73 | 74 | if let Some(json) = filtered { 75 | self.filtered_content = Some(editor::Content::with_text(&json)); 76 | } 77 | } 78 | 79 | pub fn new(res: client::Response) -> Self { 80 | let (raw, pretty, value) = pretty_body(&res.body.data); 81 | Self { 82 | result: res, 83 | content: pretty.map(|p| Content::with_text(p.as_str())), 84 | raw: Content::with_text(raw.as_str()), 85 | value, 86 | filtered_content: None, 87 | json_path_filter: None, 88 | } 89 | } 90 | } 91 | 92 | fn pretty_body(body: &[u8]) -> (String, Option, Option) { 93 | let raw = String::from_utf8_lossy(body).to_string(); 94 | 95 | let value = serde_json::from_slice::(body).ok(); 96 | let json = value 97 | .as_ref() 98 | .map(|_v| jsonformat::format(&raw, jsonformat::Indentation::TwoSpace)); 99 | 100 | (raw, json, value) 101 | } 102 | 103 | #[derive(Debug, Default)] 104 | pub enum ResponseState { 105 | #[default] 106 | Idle, 107 | Executing, 108 | Completed(Box), 109 | Failed(Arc), 110 | } 111 | 112 | #[derive(Debug)] 113 | pub struct ResponsePane { 114 | pub state: ResponseState, 115 | pub active_tab: ResponseTabId, 116 | } 117 | 118 | impl Default for ResponsePane { 119 | fn default() -> Self { 120 | Self::new() 121 | } 122 | } 123 | 124 | impl ResponsePane { 125 | pub fn new() -> Self { 126 | Self { 127 | state: ResponseState::Idle, 128 | active_tab: ResponseTabId::BodyPreview, 129 | } 130 | } 131 | 132 | pub fn is_executing(&self) -> bool { 133 | matches!(self.state, ResponseState::Executing) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/app/panels/http/panes/request/body_view.rs: -------------------------------------------------------------------------------- 1 | use super::{RequestPaneMsg, body_editor}; 2 | use crate::components::scrollable; 3 | use crate::components::{ 4 | ContentType, KeyFileList, KeyValList, icon, icon_button, icons, key_value_editor, 5 | multi_file_picker, tooltip, 6 | }; 7 | use crate::state::request::RawRequestBody; 8 | use iced::{ 9 | Element, Length, 10 | widget::{Column, Row, button, center, container, pick_list, text}, 11 | }; 12 | use std::{collections::HashSet, path::PathBuf, sync::Arc}; 13 | use strum::VariantNames; 14 | 15 | pub fn body_tab( 16 | body: &RawRequestBody, 17 | vars: Arc>, 18 | ) -> iced::Element { 19 | let actions = match body { 20 | RawRequestBody::Json(_) | RawRequestBody::XML(_) => Some(tooltip( 21 | "Prettify", 22 | icon_button(icons::Wand, None, Some(4)) 23 | .style(button::text) 24 | .on_press(RequestPaneMsg::FormatBody), 25 | )), 26 | _ => None, 27 | }; 28 | 29 | let header = Row::new() 30 | .push(text("Content Type")) 31 | .push( 32 | pick_list( 33 | RawRequestBody::VARIANTS, 34 | Some(body.as_str()), 35 | RequestPaneMsg::ChangeBodyType, 36 | ) 37 | .padding([2, 6]), 38 | ) 39 | .push(actions) 40 | .spacing(16) 41 | .height(Length::Shrink) 42 | .align_y(iced::Alignment::Center); 43 | 44 | let body = match body { 45 | RawRequestBody::Json(content) => body_editor::view(content, ContentType::Json), 46 | RawRequestBody::XML(content) => body_editor::view(content, ContentType::XML), 47 | RawRequestBody::Text(content) => body_editor::view(content, ContentType::Text), 48 | RawRequestBody::Form(values) => form(values, Arc::clone(&vars)), 49 | RawRequestBody::Multipart(values, files) => multipart_editor(values, files, vars), 50 | RawRequestBody::File(path) => file(path), 51 | RawRequestBody::None => no_body(), 52 | }; 53 | 54 | Column::new() 55 | .push(header) 56 | .push(center(body)) 57 | .spacing(8) 58 | .into() 59 | } 60 | 61 | fn file(path: &Option) -> Element { 62 | let location = path 63 | .as_ref() 64 | .map(|p| p.to_str().unwrap_or("Invalid File Path")) 65 | .unwrap_or("No File Selected"); 66 | 67 | Column::new() 68 | .push(text(location)) 69 | .push( 70 | button(text("Select File")) 71 | .padding([4, 12]) 72 | .on_press(RequestPaneMsg::OpenFilePicker) 73 | .style(button::secondary), 74 | ) 75 | .align_x(iced::Alignment::Center) 76 | .spacing(8) 77 | .into() 78 | } 79 | 80 | fn form(values: &KeyValList, vars: Arc>) -> Element { 81 | scrollable(key_value_editor(values, &vars).on_change(RequestPaneMsg::FormBodyEditAction)) 82 | .height(Length::Fill) 83 | .width(Length::Fill) 84 | .into() 85 | } 86 | 87 | fn no_body<'a>() -> Element<'a, RequestPaneMsg> { 88 | Column::new() 89 | .push(container(icon(icons::FileCancel).size(80.0)).padding(10)) 90 | .push(text("No Body Content")) 91 | .align_x(iced::Alignment::Center) 92 | .height(Length::Shrink) 93 | .width(Length::Shrink) 94 | .into() 95 | } 96 | 97 | fn multipart_editor<'a>( 98 | values: &'a KeyValList, 99 | files: &'a KeyFileList, 100 | vars: Arc>, 101 | ) -> Element<'a, RequestPaneMsg> { 102 | let params = Column::new() 103 | .push("Params") 104 | .push(key_value_editor(values, &vars).on_change(RequestPaneMsg::MultipartParamsAction)) 105 | .width(Length::Fill) 106 | .spacing(4); 107 | 108 | let file_picker = Column::new() 109 | .push("Files") 110 | .push(multi_file_picker(files).map(RequestPaneMsg::MultipartFilesAction)) 111 | .width(Length::Fill) 112 | .spacing(4); 113 | 114 | scrollable(Column::new().push(params).push(file_picker).spacing(8)) 115 | .height(Length::Fill) 116 | .into() 117 | } 118 | -------------------------------------------------------------------------------- /src/state/popups.rs: -------------------------------------------------------------------------------- 1 | use iced_auto_updater_plugin::ReleaseInfo; 2 | 3 | use crate::state::TabKey; 4 | use lib::http::CollectionKey; 5 | use lib::http::collection::{FolderId, RequestId}; 6 | use lib::http::environment::EnvironmentKey; 7 | use std::path::PathBuf; 8 | 9 | use super::CommonState; 10 | 11 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 12 | pub enum CollectionCreationMode { 13 | CreateNew, 14 | ImportPostman, 15 | } 16 | 17 | #[derive(Debug)] 18 | pub struct CreateCollectionState { 19 | pub mode: CollectionCreationMode, 20 | pub name: String, 21 | pub path: Option, 22 | // For import mode 23 | pub import_file_path: Option, 24 | pub import_target_path: Option, 25 | } 26 | 27 | #[derive(Debug)] 28 | pub struct SaveRequestState { 29 | pub tab: TabKey, 30 | pub name: String, 31 | pub col: Option, 32 | pub folder_id: Option, 33 | } 34 | 35 | #[derive(Debug, Clone)] 36 | pub enum PopupNameAction { 37 | RenameCollection(CollectionKey), 38 | RenameFolder(CollectionKey, FolderId), 39 | RenameRequest(CollectionKey, RequestId), 40 | CreateFolder(CollectionKey, Option), 41 | NewRequest(CollectionKey, Option), 42 | NewScript(CollectionKey), 43 | RenameScript(CollectionKey, String), 44 | CreateEnvironment(TabKey), 45 | RenameEnvironment(TabKey, EnvironmentKey), 46 | } 47 | 48 | #[derive(Debug)] 49 | pub struct PopupNameState { 50 | pub name: String, 51 | pub action: PopupNameAction, 52 | } 53 | 54 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 55 | pub enum AppSettingTabs { 56 | General, 57 | } 58 | 59 | #[derive(Debug)] 60 | pub struct AppSettingsState { 61 | pub active_tab: AppSettingTabs, 62 | } 63 | 64 | #[derive(Debug)] 65 | pub struct UpdateConfirmationState(pub ReleaseInfo); 66 | 67 | #[derive(Debug)] 68 | pub enum Popup { 69 | CreateCollection(CreateCollectionState), 70 | SaveRequest(SaveRequestState), 71 | PopupName(PopupNameState), 72 | AppSettings(AppSettingsState), 73 | UpdateConfirmation(UpdateConfirmationState), 74 | } 75 | 76 | impl Popup { 77 | pub fn done(&self) -> &'static str { 78 | match self { 79 | Popup::CreateCollection(state) => match state.mode { 80 | CollectionCreationMode::CreateNew => "Create", 81 | CollectionCreationMode::ImportPostman => "Import", 82 | }, 83 | Popup::SaveRequest(_) => "Save", 84 | Popup::PopupName(_) => "Ok", 85 | Popup::AppSettings(_) => "Done", 86 | Popup::UpdateConfirmation(_) => "Update", 87 | } 88 | } 89 | } 90 | 91 | fn open_popup(state: &mut CommonState, popup: Popup) { 92 | state.popup = Some(popup); 93 | } 94 | 95 | impl Popup { 96 | pub fn close(state: &mut CommonState) { 97 | state.popup.take(); 98 | } 99 | 100 | pub fn save_request(state: &mut CommonState, tab: TabKey) { 101 | let popup = Self::SaveRequest(SaveRequestState { 102 | tab, 103 | name: String::new(), 104 | col: None, 105 | folder_id: None, 106 | }); 107 | open_popup(state, popup); 108 | } 109 | 110 | pub fn popup_name(state: &mut CommonState, name: String, action: PopupNameAction) { 111 | let popup = Self::PopupName(PopupNameState { name, action }); 112 | open_popup(state, popup); 113 | } 114 | 115 | pub fn create_collection(state: &mut CommonState) { 116 | let popup = Self::CreateCollection(CreateCollectionState { 117 | mode: CollectionCreationMode::CreateNew, 118 | name: String::new(), 119 | path: None, 120 | import_file_path: None, 121 | import_target_path: None, 122 | }); 123 | open_popup(state, popup); 124 | } 125 | 126 | pub fn app_settings(state: &mut CommonState) { 127 | let popup = Self::AppSettings(AppSettingsState { 128 | active_tab: AppSettingTabs::General, 129 | }); 130 | open_popup(state, popup); 131 | } 132 | 133 | pub fn update_confirmation(state: &mut CommonState, release: ReleaseInfo) { 134 | let popup = Self::UpdateConfirmation(UpdateConfirmationState(release)); 135 | open_popup(state, popup); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/components/button_tabs.rs: -------------------------------------------------------------------------------- 1 | use crate::components::min_dimension::min_width; 2 | use iced::Alignment::Center; 3 | use iced::widget::button::Status; 4 | use iced::widget::{container, space}; 5 | use iced::{Background, Length}; 6 | use iced::{ 7 | Element, 8 | widget::{Column, Row, Text, button}, 9 | }; 10 | 11 | pub struct ButtonTab<'a, T> { 12 | pub id: T, 13 | pub label: Box Text<'a>>, 14 | } 15 | 16 | pub fn button_tab<'a, T: Eq>(id: T, label: impl Fn() -> Text<'a> + 'static) -> ButtonTab<'a, T> { 17 | ButtonTab { 18 | id, 19 | label: Box::new(label), 20 | } 21 | } 22 | 23 | pub fn button_tabs<'a, T: Eq + Clone, M: 'a + Clone>( 24 | active: T, 25 | tabs: impl Iterator>, 26 | on_tab_change: impl Fn(T) -> M, 27 | suffix: Option>, 28 | ) -> Element<'a, M> { 29 | let tabs = tab_list(active, tabs, on_tab_change, suffix, false); 30 | Column::new() 31 | .push(Row::from_vec(tabs).spacing(2).width(iced::Length::Fill)) 32 | .width(iced::Length::Fill) 33 | .height(iced::Length::Shrink) 34 | .align_x(Center) 35 | .into() 36 | } 37 | 38 | pub fn vertical_button_tabs<'a, T: Eq + Clone, M: 'a + Clone>( 39 | active: T, 40 | tabs: impl Iterator>, 41 | on_tab_change: impl Fn(T) -> M, 42 | ) -> Row<'a, M> { 43 | let tabs = tab_list(active, tabs, on_tab_change, None, true); 44 | Row::new() 45 | .push(Column::from_vec(tabs).spacing(4).align_x(Center)) 46 | .width(iced::Length::Shrink) 47 | .height(iced::Length::Shrink) 48 | } 49 | 50 | fn tab_list<'a, T: Eq + Clone, M: 'a + Clone>( 51 | active: T, 52 | tabs: impl Iterator>, 53 | on_tab_change: impl Fn(T) -> M + Sized, 54 | suffix: Option>, 55 | vertical: bool, 56 | ) -> Vec> { 57 | let mut tabs_row = Vec::new(); 58 | for tab in tabs { 59 | let active = tab.id == active; 60 | let tab_button = |width: Length| { 61 | let btn = button((tab.label)()) 62 | .style(move |theme, _| button::text(theme, Status::Hovered)) 63 | .width(width) 64 | .padding([2, 6]) 65 | .on_press(on_tab_change(tab.id.clone())); 66 | 67 | if active { 68 | Column::new() 69 | .push(btn.style(|theme, _| { 70 | let palette = theme.extended_palette(); 71 | let mut style = button::text(theme, Status::Active); 72 | style.background = None; 73 | style.text_color = palette.background.strong.text; 74 | style 75 | })) 76 | .push( 77 | container(space::horizontal()) 78 | .width(iced::Length::Fill) 79 | .height(2.0) 80 | .style(move |theme: &iced::Theme| { 81 | let palette = theme.extended_palette(); 82 | container::Style { 83 | background: Some(Background::Color( 84 | palette.primary.strong.color, 85 | )), 86 | ..Default::default() 87 | } 88 | }), 89 | ) 90 | .width(width) 91 | } else { 92 | Column::new() 93 | .push(btn) 94 | .push(space::vertical().height(2.0)) 95 | .width(width) 96 | } 97 | }; 98 | 99 | tabs_row.push(if vertical { 100 | min_width(tab_button(Length::Shrink), tab_button(Length::Fill), 100.).into() 101 | } else { 102 | tab_button(Length::Shrink).into() 103 | }); 104 | } 105 | 106 | if let Some(suffix) = suffix { 107 | tabs_row.extend([ 108 | vertical 109 | .then(space::vertical) 110 | .unwrap_or(space::horizontal()) 111 | .into(), 112 | suffix, 113 | ]); 114 | } 115 | 116 | tabs_row 117 | } 118 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "Sanchaar" 3 | version.workspace = true 4 | edition.workspace = true 5 | description.workspace = true 6 | 7 | [package.metadata.packager] 8 | product-name = "Sanchaar" 9 | identifier = "com.nrjais.sanchaar" 10 | icons = [ 11 | "assets/16x16.png", 12 | "assets/32x32.png", 13 | "assets/48x48.png", 14 | "assets/128x128.png", 15 | "assets/256x256.png", 16 | "assets/512x512.png", 17 | "assets/sanchaar.ico", 18 | ] 19 | 20 | [package.metadata.packager.macos] 21 | signing-identity = "-" 22 | 23 | [workspace] 24 | members = ["crates/core", "crates/parsers"] 25 | 26 | [workspace.package] 27 | version = "0.1.0-prerelease.1" 28 | edition = "2024" 29 | description = "A fast offline REST API Client" 30 | 31 | [workspace.dependencies] 32 | anyhow = "1.0" 33 | chrono = { version = "0.4", features = ["serde"] } 34 | clap = { version = "4.5", features = ["derive"] } 35 | cookie_store = "0.22" 36 | directories = "6.0" 37 | dotenvy = "0.15.3" 38 | env_logger = "0.11" 39 | futures = "0.3" 40 | humansize = "2.1" 41 | iced = { git = "https://github.com/nrjais/iced", branch = "editor", features = [ 42 | "advanced", 43 | "highlighter", 44 | "lazy", 45 | "sipper", 46 | "tokio", 47 | "web-colors", 48 | "wgpu", 49 | ] } 50 | iced_auto_updater_plugin = { git = "https://github.com/nrjais/iced_plugins", branch = "fork" } 51 | iced_core = { git = "https://github.com/nrjais/iced", branch = "editor", features = [ 52 | "advanced", 53 | ] } 54 | iced_drop = { git = "https://github.com/nrjais/iced_drop", branch = "master" } 55 | iced_plugins = { git = "https://github.com/nrjais/iced_plugins", branch = "fork" } 56 | iced_window_state_plugin = { git = "https://github.com/nrjais/iced_plugins", branch = "fork" } 57 | indexmap = "2" 58 | jsonformat = "2" 59 | jsonpath-rust = "1" 60 | jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } 61 | log = "0.4" 62 | mime_guess = "2.0" 63 | parsers = { path = "crates/parsers" } 64 | pest = "2.8" 65 | pest_derive = "2.8" 66 | regex = "1.12" 67 | rquickjs = { version = "0.10", features = [ 68 | "array-buffer", 69 | "classes", 70 | "loader", 71 | "parallel", 72 | ] } 73 | reqwest = { version = "0.12", default-features = false, features = [ 74 | "multipart", 75 | "rustls-tls", 76 | "stream", 77 | ] } 78 | reqwest_cookie_store = "0.9" 79 | rfd = { version = "0.16", default-features = false, features = ["xdg-portal"] } 80 | serde = { version = "1.0", features = ["derive"] } 81 | serde_json = "1.0" 82 | serde_with = "3.9.0" 83 | similar = "2.5" 84 | sqlx = { version = "0.8", features = [ 85 | "chrono", 86 | "runtime-tokio", 87 | "sqlite", 88 | "uuid", 89 | ] } 90 | strum = { version = "0.27", features = ["derive"] } 91 | tokio = { version = "1.36", features = ["fs", "io-util", "macros"] } 92 | toml = "0.9" 93 | urlencoding = "2.1" 94 | uuid = { version = "1.0", features = ["serde", "v4"] } 95 | 96 | [dependencies] 97 | anyhow.workspace = true 98 | chrono.workspace = true 99 | cookie_store.workspace = true 100 | directories.workspace = true 101 | env_logger.workspace = true 102 | futures.workspace = true 103 | humansize.workspace = true 104 | iced.workspace = true 105 | iced_auto_updater_plugin.workspace = true 106 | iced_core.workspace = true 107 | iced_drop.workspace = true 108 | iced_plugins.workspace = true 109 | iced_window_state_plugin.workspace = true 110 | indexmap.workspace = true 111 | jsonformat.workspace = true 112 | jsonpath-rust.workspace = true 113 | lib = { package = "core", path = "crates/core" } 114 | log.workspace = true 115 | parsers.workspace = true 116 | reqwest.workspace = true 117 | reqwest_cookie_store.workspace = true 118 | serde.workspace = true 119 | serde_json.workspace = true 120 | sqlx.workspace = true 121 | strum.workspace = true 122 | tokio.workspace = true 123 | uuid.workspace = true 124 | 125 | [target.'cfg(not(target_os = "linux"))'.dependencies] 126 | rfd = { workspace = true, default-features = false, features = ["tokio"] } 127 | 128 | [target.'cfg(target_os = "linux")'.dependencies] 129 | rfd = { workspace = true, default-features = false, features = [ 130 | "async-std", 131 | "xdg-portal", 132 | ] } 133 | 134 | [features] 135 | default = ["iced/debug", "iced/hot"] 136 | 137 | [profile.dev] 138 | debug = 0 139 | incremental = true 140 | 141 | [profile.dev.package."*"] 142 | opt-level = 3 143 | 144 | [profile.release] 145 | opt-level = 3 146 | strip = true 147 | lto = true 148 | codegen-units = 1 149 | -------------------------------------------------------------------------------- /crates/cli/src/color.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::io::Write; 3 | use std::ops::Add; 4 | 5 | pub struct Color { 6 | pub r: u8, 7 | pub g: u8, 8 | pub b: u8, 9 | } 10 | 11 | pub struct Weight { 12 | pub id: i8, 13 | } 14 | 15 | impl Color { 16 | pub const BLACK: Color = Color { r: 0, g: 0, b: 0 }; 17 | pub const RED: Color = Color { 18 | r: 255, 19 | g: 78, 20 | b: 78, 21 | }; 22 | pub const ORANGE: Color = Color { 23 | r: 255, 24 | g: 158, 25 | b: 86, 26 | }; 27 | pub const YELLOW: Color = Color { 28 | r: 255, 29 | g: 240, 30 | b: 86, 31 | }; 32 | pub const LIGHTGREEN: Color = Color { 33 | r: 102, 34 | g: 255, 35 | b: 124, 36 | }; 37 | pub const DARKGREEN: Color = Color { 38 | r: 27, 39 | g: 141, 40 | b: 43, 41 | }; 42 | pub const MINT: Color = Color { 43 | r: 65, 44 | g: 255, 45 | b: 160, 46 | }; 47 | pub const CYAN: Color = Color { 48 | r: 74, 49 | g: 255, 50 | b: 252, 51 | }; 52 | pub const LIGHTBLUE: Color = Color { 53 | r: 88, 54 | g: 221, 55 | b: 255, 56 | }; 57 | pub const SKYBLUE: Color = Color { 58 | r: 0, 59 | g: 169, 60 | b: 255, 61 | }; 62 | pub const BLUE: Color = Color { 63 | r: 0, 64 | g: 91, 65 | b: 255, 66 | }; 67 | pub const LIGHTPURPLE: Color = Color { 68 | r: 0, 69 | g: 31, 70 | b: 255, 71 | }; 72 | pub const DARKBLUE: Color = Color { 73 | r: 0, 74 | g: 31, 75 | b: 255, 76 | }; 77 | pub const DEEPPURPLE: Color = Color { 78 | r: 78, 79 | g: 0, 80 | b: 255, 81 | }; 82 | pub const PURPLE: Color = Color { 83 | r: 123, 84 | g: 0, 85 | b: 255, 86 | }; 87 | pub const VIOLET: Color = Color { 88 | r: 172, 89 | g: 108, 90 | b: 255, 91 | }; 92 | pub const MAGENTA: Color = Color { 93 | r: 213, 94 | g: 0, 95 | b: 255, 96 | }; 97 | pub const WARMPINK: Color = Color { 98 | r: 255, 99 | g: 0, 100 | b: 255, 101 | }; 102 | pub const WATERMELON: Color = Color { 103 | r: 255, 104 | g: 113, 105 | b: 166, 106 | }; 107 | pub const LIGHTGRAY: Color = Color { 108 | r: 153, 109 | g: 153, 110 | b: 153, 111 | }; 112 | pub const DARKGRAY: Color = Color { 113 | r: 91, 114 | g: 91, 115 | b: 91, 116 | }; 117 | } 118 | 119 | impl Weight { 120 | pub const BOLD: Weight = Weight { id: 1 }; 121 | pub const DIM: Weight = Weight { id: 2 }; 122 | pub const ITALIC: Weight = Weight { id: 3 }; 123 | pub const UNDERLINE: Weight = Weight { id: 4 }; 124 | pub const SLOWBLINK: Weight = Weight { id: 5 }; 125 | pub const FASTBLINK: Weight = Weight { id: 6 }; 126 | } 127 | 128 | impl Add<&str> for Color { 129 | type Output = String; 130 | 131 | fn add(self, rhs: &str) -> Self::Output { 132 | format!( 133 | "\x1B[38;2;{};{};{}m{}{}", 134 | self.r, self.g, self.b, rhs, "\x1B[0m" 135 | ) 136 | } 137 | } 138 | 139 | impl Add<&str> for Weight { 140 | type Output = String; 141 | 142 | fn add(self, rhs: &str) -> Self::Output { 143 | return format!("\x1B[{}m{}\x1B[0m", self.id, rhs); 144 | } 145 | } 146 | 147 | pub fn color(text: &str, clr: Color) -> String { 148 | return clr + text; 149 | } 150 | 151 | pub fn weigh(text: &str, wht: Weight) -> String { 152 | return wht + text; 153 | } 154 | 155 | pub fn input_color(iptext: &str, clr: Color) -> String { 156 | let mut ip = String::new(); 157 | print!("{}\x1B[38;2;{};{};{}m", iptext, clr.r, clr.g, clr.b); 158 | io::stdout().flush().expect("Failed to flush stdout"); 159 | io::stdin().read_line(&mut ip).expect("Error getting input"); 160 | print!("\x1B[0m"); 161 | return ip.trim_end().to_owned(); 162 | } 163 | 164 | #[cfg(test)] 165 | mod tests { 166 | use super::*; 167 | 168 | #[test] 169 | fn it_works() { 170 | println!( 171 | "{}", 172 | color( 173 | "red", 174 | Color { 175 | r: 255, 176 | g: 255, 177 | b: 255 178 | } 179 | ) 180 | ); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/components/editor/highlighters.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashSet, ops::Range, sync::Arc}; 2 | 3 | use iced::highlighter::Highlight; 4 | use iced_core::text::{Highlighter, highlighter::Format}; 5 | use parsers::Token; 6 | 7 | use crate::components::colors; 8 | 9 | pub trait IsDefined: Clone + PartialEq { 10 | fn is_defined(&self, name: &str) -> bool; 11 | } 12 | 13 | impl IsDefined for Arc> { 14 | fn is_defined(&self, name: &str) -> bool { 15 | self.contains(name) 16 | } 17 | } 18 | 19 | #[derive(Debug, Clone, PartialEq)] 20 | pub struct TemplHighlighterSettings(T); 21 | 22 | impl TemplHighlighterSettings { 23 | pub fn new(vars: T) -> Self { 24 | Self(vars) 25 | } 26 | } 27 | 28 | #[derive(Debug, Clone, Default)] 29 | pub struct TemplHighlighter { 30 | vars: T, 31 | current_line: usize, 32 | } 33 | 34 | impl Highlighter for TemplHighlighter { 35 | type Settings = TemplHighlighterSettings; 36 | 37 | type Highlight = Format; 38 | 39 | type Iterator<'a> = Box, Format)> + 'a>; 40 | 41 | fn new(s: &Self::Settings) -> Self { 42 | Self { 43 | vars: s.0.clone(), 44 | current_line: 0, 45 | } 46 | } 47 | 48 | fn update(&mut self, s: &Self::Settings) { 49 | self.vars = s.0.clone(); 50 | self.current_line = 0; 51 | } 52 | 53 | fn change_line(&mut self, _line: usize) { 54 | self.current_line = 0; 55 | } 56 | 57 | fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> { 58 | let mut ranges = Vec::new(); 59 | let parsed = parsers::parse_template(line); 60 | 61 | for span in parsed { 62 | if let Token::Variable(name) = span.token { 63 | let color = if self.vars.is_defined(&name) { 64 | colors::LIGHT_GREEN 65 | } else { 66 | colors::INDIAN_RED 67 | }; 68 | 69 | ranges.push(( 70 | span.start..span.end, 71 | Format { 72 | color: Some(color), 73 | font: None, 74 | }, 75 | )); 76 | } 77 | } 78 | 79 | Box::new(ranges.into_iter()) 80 | } 81 | 82 | fn current_line(&self) -> usize { 83 | self.current_line 84 | } 85 | } 86 | 87 | pub struct StackedHighlighter { 88 | first: H1, 89 | second: H2, 90 | } 91 | 92 | trait ToFormat { 93 | fn to_format(&self) -> Format; 94 | } 95 | 96 | impl ToFormat for Format { 97 | fn to_format(&self) -> Format { 98 | *self 99 | } 100 | } 101 | 102 | impl ToFormat for Highlight { 103 | fn to_format(&self) -> Format { 104 | Highlight::to_format(self) 105 | } 106 | } 107 | 108 | impl Highlighter for StackedHighlighter 109 | where 110 | H1: Highlighter, 111 | H2: Highlighter, 112 | H1::Highlight: ToFormat, 113 | H2::Highlight: ToFormat, 114 | { 115 | type Settings = (H1::Settings, H2::Settings); 116 | 117 | type Highlight = Format; 118 | 119 | type Iterator<'a> = Box, Format)> + 'a>; 120 | 121 | fn new(settings: &Self::Settings) -> Self { 122 | Self { 123 | first: H1::new(&settings.0), 124 | second: H2::new(&settings.1), 125 | } 126 | } 127 | 128 | fn update(&mut self, new_settings: &Self::Settings) { 129 | self.first.update(&new_settings.0); 130 | self.second.update(&new_settings.1); 131 | } 132 | 133 | fn change_line(&mut self, line: usize) { 134 | self.first.change_line(line); 135 | self.second.change_line(line); 136 | } 137 | 138 | fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> { 139 | let first = self 140 | .first 141 | .highlight_line(line) 142 | .map(|(range, highlight)| (range, highlight.to_format())); 143 | let second = self 144 | .second 145 | .highlight_line(line) 146 | .map(|(range, highlight)| (range, highlight.to_format())); 147 | 148 | Box::new(first.chain(second)) 149 | } 150 | 151 | fn current_line(&self) -> usize { 152 | self.first.current_line() 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/components/line_editor.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | use std::sync::Arc; 3 | 4 | use iced::advanced::widget; 5 | use iced::{Element, Length, Pixels, Theme}; 6 | use iced_core::text::Wrapping; 7 | use iced_core::text::editor::{Action, Edit}; 8 | 9 | use crate::components::editor::highlighters::{TemplHighlighter, TemplHighlighterSettings}; 10 | use crate::components::editor::{self, ContentAction, Status, StyleFn, text_editor}; 11 | 12 | pub struct LineEditor<'a> { 13 | pub code: &'a editor::Content, 14 | pub editable: bool, 15 | pub placeholder: Option<&'a str>, 16 | pub var_set: Arc>, 17 | pub highlight: bool, 18 | id: Option, 19 | text_size: Option, 20 | style: StyleFn<'a, Theme>, 21 | } 22 | 23 | impl<'a> LineEditor<'a> { 24 | pub fn editable(mut self) -> Self { 25 | self.editable = true; 26 | self 27 | } 28 | 29 | pub fn placeholder(mut self, placeholder: &'a str) -> Self { 30 | self.placeholder = Some(placeholder); 31 | self 32 | } 33 | 34 | /// Sets the text size of the [`TextEditor`]. 35 | pub fn size(mut self, size: impl Into) -> Self { 36 | self.text_size = Some(size.into()); 37 | self 38 | } 39 | 40 | pub fn style(mut self, style: impl Fn(&Theme, Status) -> editor::Style + 'a) -> Self { 41 | self.style = Box::new(style); 42 | self 43 | } 44 | 45 | pub fn vars(mut self, vars: Arc>) -> Self { 46 | self.var_set = vars; 47 | self 48 | } 49 | 50 | pub fn highlight(mut self, highlight: bool) -> Self { 51 | self.highlight = highlight; 52 | self 53 | } 54 | 55 | pub fn id(mut self, id: widget::Id) -> Self { 56 | self.id = Some(id); 57 | self 58 | } 59 | 60 | pub fn map(self, f: impl Fn(LineEditorMsg) -> M + 'a) -> Element<'a, M> { 61 | self.view().map(f) 62 | } 63 | 64 | pub fn view(self) -> Element<'a, LineEditorMsg> { 65 | let editor = text_editor(self.code) 66 | .height(Length::Shrink) 67 | .wrapping(Wrapping::WordOrGlyph) 68 | .style(self.style) 69 | .on_action(move |ac| LineEditorMsg::EditorAction(ac, self.editable)); 70 | 71 | let editor = if let Some(placeholder) = self.placeholder { 72 | editor.placeholder(placeholder) 73 | } else { 74 | editor 75 | }; 76 | 77 | let editor = if let Some(size) = self.text_size { 78 | editor.size(size) 79 | } else { 80 | editor 81 | }; 82 | 83 | let editor = if let Some(id) = self.id.as_ref() { 84 | editor.id(id.clone()) 85 | } else { 86 | editor 87 | }; 88 | 89 | if self.highlight { 90 | let settings = TemplHighlighterSettings::new(Arc::clone(&self.var_set)); 91 | editor 92 | .highlight_with::>>>(settings, |f, _| *f) 93 | .into() 94 | } else { 95 | editor.into() 96 | } 97 | } 98 | } 99 | 100 | #[derive(Debug, Clone)] 101 | pub enum LineEditorMsg { 102 | EditorAction(ContentAction, bool), 103 | } 104 | 105 | impl LineEditorMsg { 106 | pub fn update(self, state: &mut editor::Content) { 107 | match self { 108 | Self::EditorAction(action, editable) => { 109 | let block = matches!( 110 | action, 111 | ContentAction::Action(Action::Edit(Edit::Enter)) 112 | | ContentAction::Action(Action::Scroll { .. }) 113 | ); 114 | let allowed = !action.is_edit() || editable; 115 | 116 | if allowed && !block { 117 | state.perform(action); 118 | } 119 | } 120 | } 121 | } 122 | } 123 | 124 | pub fn line_editor<'a>(code: &'a editor::Content) -> LineEditor<'a> { 125 | LineEditor { 126 | code, 127 | editable: true, 128 | highlight: true, 129 | placeholder: None, 130 | text_size: None, 131 | style: Box::new(|theme: &iced::Theme, status| match status { 132 | Status::Focused { .. } => editor::default(theme, Status::Focused { is_hovered: true }), 133 | _ => editor::default(theme, Status::Active), 134 | }), 135 | var_set: HashSet::new().into(), 136 | id: None, 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /crates/parsers/src/lib.rs: -------------------------------------------------------------------------------- 1 | use pest::{Parser, iterators::Pairs}; 2 | use pest_derive::Parser; 3 | 4 | // Token types for the parser 5 | #[derive(Debug, PartialEq)] 6 | pub enum Token { 7 | Text(String), 8 | Variable(String), 9 | // Use ! to escape the variable, only first '!' will be stripped 10 | Escaped(String), 11 | } 12 | 13 | #[derive(Debug, PartialEq)] 14 | pub struct Span { 15 | pub token: Token, 16 | pub start: usize, 17 | pub end: usize, 18 | } 19 | 20 | #[derive(Parser)] 21 | #[grammar = "template.pest"] 22 | struct TemplateParser; 23 | 24 | pub fn parse_template(text: &str) -> Vec { 25 | let pairs = TemplateParser::parse(Rule::template, text).unwrap(); 26 | 27 | let mut tokens = Vec::new(); 28 | 29 | add_template_tokens(pairs, &mut tokens); 30 | 31 | tokens 32 | } 33 | 34 | fn add_template_tokens(pairs: Pairs, tokens: &mut Vec) { 35 | for pair in pairs { 36 | match pair.as_rule() { 37 | Rule::template => add_template_tokens(pair.into_inner(), tokens), 38 | Rule::text => { 39 | tokens.push(Span { 40 | token: Token::Text(pair.as_str().to_string()), 41 | start: pair.as_span().start(), 42 | end: pair.as_span().end(), 43 | }); 44 | } 45 | Rule::variable => { 46 | let span = pair.as_span(); 47 | let inner = pair.into_inner(); 48 | for inner_pair in inner { 49 | if inner_pair.as_rule() == Rule::ident { 50 | let var = inner_pair.as_str().to_string(); 51 | if let Some(var) = var.strip_prefix('!') { 52 | let text = format!("{{{}}}", var); 53 | tokens.push(Span { 54 | token: Token::Escaped(text), 55 | start: span.start(), 56 | end: span.end(), 57 | }); 58 | } else { 59 | tokens.push(Span { 60 | token: Token::Variable(var), 61 | start: span.start(), 62 | end: span.end(), 63 | }); 64 | } 65 | } 66 | } 67 | } 68 | _ => (), 69 | } 70 | } 71 | } 72 | 73 | #[cfg(test)] 74 | mod tests { 75 | use super::*; 76 | 77 | #[test] 78 | fn test_parse_template() { 79 | let text = "Hello, {{name}}!"; 80 | let tokens = parse_template(text); 81 | assert_eq!(tokens.len(), 3); 82 | assert_eq!(tokens[0].token, Token::Text("Hello, ".to_string())); 83 | assert_eq!(tokens[1].token, Token::Variable("name".to_string())); 84 | assert_eq!(tokens[2].token, Token::Text("!".to_string())); 85 | 86 | let text = "Hello, {{name}}! text"; 87 | let tokens = parse_template(text); 88 | assert_eq!(tokens.len(), 3); 89 | assert_eq!(tokens[0].token, Token::Text("Hello, ".to_string())); 90 | assert_eq!(tokens[1].token, Token::Variable("name".to_string())); 91 | assert_eq!(tokens[2].token, Token::Text("! text".to_string())); 92 | 93 | let text = "{{name}}! {{age}}"; 94 | let tokens = parse_template(text); 95 | assert_eq!(tokens.len(), 3); 96 | assert_eq!(tokens[0].token, Token::Variable("name".to_string())); 97 | assert_eq!(tokens[1].token, Token::Text("! ".to_string())); 98 | assert_eq!(tokens[2].token, Token::Variable("age".to_string())); 99 | 100 | let text = "Hello, {{!name}}! {{age}}"; 101 | let tokens = parse_template(text); 102 | assert_eq!(tokens.len(), 4); 103 | assert_eq!(tokens[0].token, Token::Text("Hello, ".to_string())); 104 | assert_eq!(tokens[1].token, Token::Escaped("name".to_string())); 105 | assert_eq!(tokens[2].token, Token::Text("! ".to_string())); 106 | assert_eq!(tokens[3].token, Token::Variable("age".to_string())); 107 | 108 | let text = "Hello, {{!!name}}! {{age}}"; 109 | let tokens = parse_template(text); 110 | assert_eq!(tokens.len(), 4); 111 | assert_eq!(tokens[0].token, Token::Text("Hello, ".to_string())); 112 | assert_eq!(tokens[1].token, Token::Escaped("!name".to_string())); 113 | assert_eq!(tokens[2].token, Token::Text("! ".to_string())); 114 | assert_eq!(tokens[3].token, Token::Variable("age".to_string())); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/app/panels/http/action_bar.rs: -------------------------------------------------------------------------------- 1 | use crate::components::{self, NerdIcon, icons}; 2 | use iced::widget::{Button, Column, Row, pick_list, space, text, text_input}; 3 | use iced::{Element, Length, Task, widget::button}; 4 | use lib::http::CollectionKey; 5 | use lib::http::collection::Collection; 6 | 7 | use crate::commands::builders; 8 | use crate::state::tabs::collection_tab::CollectionTab; 9 | use crate::state::{AppState, HttpTab, Tab}; 10 | 11 | #[derive(Debug, Clone)] 12 | pub enum ActionBarMsg { 13 | SubmitNameEdit, 14 | UpdateName(String), 15 | StartNameEdit, 16 | OpenEnvironments(CollectionKey), 17 | RequestRenamed(String), 18 | SelectEnvironment(String), 19 | } 20 | 21 | impl ActionBarMsg { 22 | pub fn update(self, state: &mut AppState) -> Task { 23 | let Some(Tab::Http(tab)) = state.active_tab_mut() else { 24 | return Task::none(); 25 | }; 26 | 27 | match self { 28 | ActionBarMsg::StartNameEdit => { 29 | tab.editing_name.replace(tab.name.clone()); 30 | Task::none() 31 | } 32 | ActionBarMsg::SubmitNameEdit => { 33 | let Some(name) = tab.editing_name.take() else { 34 | return Task::none(); 35 | }; 36 | 37 | let req = tab.collection_ref; 38 | builders::rename_request_cmd(&mut state.common, req, name.clone()) 39 | .map(move |_| ActionBarMsg::RequestRenamed(name.clone())) 40 | } 41 | ActionBarMsg::UpdateName(name) => { 42 | tab.editing_name.replace(name); 43 | Task::none() 44 | } 45 | ActionBarMsg::OpenEnvironments(key) => { 46 | if let Some(col) = state.common.collections.get(key) { 47 | state.open_tab(Tab::Collection(CollectionTab::new(key, col))); 48 | } 49 | Task::none() 50 | } 51 | ActionBarMsg::RequestRenamed(name) => { 52 | tab.name = name; 53 | Task::none() 54 | } 55 | ActionBarMsg::SelectEnvironment(name) => { 56 | let key = tab.collection_key(); 57 | if let Some(col) = state.common.collections.get_mut(key) { 58 | col.update_active_env_by_name(&name); 59 | }; 60 | Task::none() 61 | } 62 | } 63 | } 64 | } 65 | 66 | fn icon_button<'a>(ico: NerdIcon) -> Button<'a, ActionBarMsg> { 67 | components::icon_button(ico, None, Some(8)).style(button::text) 68 | } 69 | 70 | pub fn view<'a>(tab: &'a HttpTab, col: &'a Collection) -> Element<'a, ActionBarMsg> { 71 | let name: Element = match &tab.editing_name { 72 | Some(name) => text_input("Request Name", name) 73 | .on_input(ActionBarMsg::UpdateName) 74 | .on_paste(ActionBarMsg::UpdateName) 75 | .on_submit(ActionBarMsg::SubmitNameEdit) 76 | .padding(2) 77 | .into(), 78 | _ => text(&tab.name).into(), 79 | }; 80 | 81 | let edit_name = tab 82 | .editing_name 83 | .as_ref() 84 | .map(|_| icon_button(icons::CheckBold).on_press(ActionBarMsg::SubmitNameEdit)) 85 | .unwrap_or_else(|| icon_button(icons::Pencil).on_press(ActionBarMsg::StartNameEdit)); 86 | 87 | let bar = Row::new() 88 | .push(name) 89 | .push(edit_name) 90 | .push(space::horizontal()) 91 | .push(environment_view(col, tab.collection_ref.0)) 92 | .align_y(iced::Alignment::Center) 93 | .width(Length::Fill); 94 | 95 | Column::new().push(bar).spacing(2).into() 96 | } 97 | 98 | fn environment_view(col: &Collection, key: CollectionKey) -> Element<'_, ActionBarMsg> { 99 | let envs = col 100 | .environments 101 | .entries() 102 | .map(|(_, env)| &env.name) 103 | .collect::>(); 104 | 105 | let env_placeholder = if !envs.is_empty() { 106 | "Select Environment" 107 | } else { 108 | "No Environments" 109 | }; 110 | 111 | let selected = col.get_active_environment().map(|env| &env.name); 112 | 113 | let picker = pick_list(envs, selected, |name| { 114 | ActionBarMsg::SelectEnvironment(name.to_owned()) 115 | }) 116 | .width(Length::Shrink) 117 | .padding([2, 4]) 118 | .placeholder(env_placeholder); 119 | 120 | let settings = icon_button(icons::Gear).on_press(ActionBarMsg::OpenEnvironments(key)); 121 | 122 | Row::new() 123 | .push(picker) 124 | .push(settings) 125 | .align_y(iced::Alignment::Center) 126 | .into() 127 | } 128 | -------------------------------------------------------------------------------- /src/app/bottom_bar.rs: -------------------------------------------------------------------------------- 1 | use crate::components::{NerdIcon, bordered_top, icon, icons, split, tooltip}; 2 | use iced::{ 3 | Alignment, Element, Task, 4 | widget::{Row, Tooltip, button, space, text}, 5 | }; 6 | use iced::{border, padding}; 7 | 8 | use crate::state::{AppState, Tab, UpdateStatus, popups::Popup, tabs::cookies_tab::CookiesTab}; 9 | use iced_auto_updater_plugin::ReleaseInfo; 10 | 11 | #[derive(Debug, Clone)] 12 | pub enum BottomBarMsg { 13 | OpenSettings, 14 | OpenCookies, 15 | ToggleSplit, 16 | ToggleSideBar, 17 | OpenUpdateConfirmation(ReleaseInfo), 18 | } 19 | 20 | impl BottomBarMsg { 21 | pub fn update(self, state: &mut AppState) -> Task { 22 | use BottomBarMsg::*; 23 | 24 | match self { 25 | ToggleSideBar => { 26 | state.pane_config.toggle_side_bar(); 27 | Task::none() 28 | } 29 | OpenSettings => { 30 | Popup::app_settings(&mut state.common); 31 | Task::none() 32 | } 33 | OpenCookies => { 34 | state.open_unique_tab(Tab::CookieStore(CookiesTab::new(&state.common))); 35 | Task::none() 36 | } 37 | ToggleSplit => { 38 | state.split_direction = state.split_direction.opposite(); 39 | Task::none() 40 | } 41 | OpenUpdateConfirmation(release) => { 42 | Popup::update_confirmation(&mut state.common, release); 43 | Task::none() 44 | } 45 | } 46 | } 47 | } 48 | 49 | fn icon_button<'a>( 50 | ico: NerdIcon, 51 | on_press: BottomBarMsg, 52 | size: Option, 53 | desc: &'a str, 54 | ) -> Tooltip<'a, BottomBarMsg> { 55 | let btn = button(icon(ico).size(size.unwrap_or(16))) 56 | .on_press(on_press) 57 | .style(|t, s| button::Style { 58 | border: border::rounded(50), 59 | ..button::text(t, s) 60 | }) 61 | .padding(4); 62 | 63 | tooltip(desc, btn) 64 | } 65 | 66 | pub fn view(state: &AppState) -> Element { 67 | use BottomBarMsg::*; 68 | 69 | let side_bar_icon = if state.pane_config.side_bar_open { 70 | icons::CloseSideBar 71 | } else { 72 | icons::OpenSideBar 73 | }; 74 | 75 | let split_icon = match state.split_direction { 76 | split::Direction::Vertical => icons::SplitVertical, 77 | split::Direction::Horizontal => icons::SplitHorizontal, 78 | }; 79 | 80 | let buttons = Row::new() 81 | .push(icon_button( 82 | side_bar_icon, 83 | ToggleSideBar, 84 | Some(12), 85 | "Toggle Side Panel", 86 | )) 87 | .push(icon_button(icons::Gear, OpenSettings, None, "Settings")) 88 | .push(icon_button(icons::Cookie, OpenCookies, None, "Cookies")) 89 | .push(icon_button(split_icon, ToggleSplit, None, "Toggle Split")) 90 | .spacing(16) 91 | .align_y(Alignment::Center); 92 | 93 | let update_status = match state.update_status { 94 | UpdateStatus::None => None, 95 | UpdateStatus::Available(ref release) => Some((None, icons::Download, Some(release))), 96 | UpdateStatus::Downloading => Some((Some("Downloading"), icons::DotsCircle, None)), 97 | UpdateStatus::Installing => Some((Some("Installing"), icons::DotsCircle, None)), 98 | UpdateStatus::Completed => Some((Some("Restart to apply"), icons::Replay, None)), 99 | }; 100 | 101 | let mut row = Row::new() 102 | .push(buttons) 103 | .padding(padding::left(8).right(12)) 104 | .push(space::horizontal()) 105 | .align_y(Alignment::Center); 106 | 107 | if let Some((status_text, status_icon, release)) = update_status { 108 | let status_text = status_text.map(|s| text(s).size(12)); 109 | let status_content = Row::new() 110 | .push(icon(status_icon).size(16)) 111 | .push(status_text) 112 | .spacing(8) 113 | .align_y(Alignment::Center); 114 | 115 | let status_display: Element = if let Some(release) = release { 116 | let btn = button(status_content) 117 | .on_press(BottomBarMsg::OpenUpdateConfirmation(release.clone())) 118 | .style(|t, s| button::Style { 119 | border: border::rounded(50), 120 | ..button::text(t, s) 121 | }) 122 | .padding(4); 123 | 124 | tooltip("Update available", btn).into() 125 | } else { 126 | status_content.into() 127 | }; 128 | 129 | row = row.push(status_display); 130 | } 131 | 132 | bordered_top(2, row) 133 | } 134 | -------------------------------------------------------------------------------- /src/app/popups/name_popup.rs: -------------------------------------------------------------------------------- 1 | use lib::http::CollectionRequest; 2 | use lib::http::request::Request; 3 | use std::borrow::Cow; 4 | 5 | use iced::widget::{Column, Row, space, text, text_input}; 6 | use iced::{Element, Task}; 7 | 8 | use crate::commands::builders::{ 9 | create_folder_cmd, create_new_request_cmd, create_script_cmd, rename_folder_cmd, 10 | rename_request_cmd, rename_script_cmd, 11 | }; 12 | use crate::state::popups::{Popup, PopupNameAction, PopupNameState}; 13 | use crate::state::{AppState, Tab}; 14 | 15 | #[derive(Debug, Clone)] 16 | pub enum Message { 17 | NameChanged(String), 18 | Rename(String), 19 | Done, 20 | } 21 | 22 | impl Message { 23 | pub fn update(self, state: &mut AppState) -> Task { 24 | match self { 25 | Message::NameChanged(name) => { 26 | let Some(Popup::PopupName(data)) = state.common.popup.as_mut() else { 27 | return Task::none(); 28 | }; 29 | data.name = name; 30 | Task::none() 31 | } 32 | Message::Rename(name) => { 33 | let action = { 34 | let Some(Popup::PopupName(data)) = state.common.popup.as_ref() else { 35 | return Task::none(); 36 | }; 37 | data.action.clone() 38 | }; 39 | 40 | match action { 41 | PopupNameAction::RenameCollection(col) => { 42 | state.common.collections.rename_collection(col, name); 43 | Task::done(Message::Done) 44 | } 45 | PopupNameAction::RenameFolder(col, folder_id) => { 46 | rename_folder_cmd(&mut state.common, col, folder_id, name) 47 | .map(|_| Message::Done) 48 | } 49 | PopupNameAction::CreateFolder(col, folder_id) => { 50 | create_folder_cmd(&mut state.common, col, folder_id, name) 51 | .map(|_| Message::Done) 52 | } 53 | PopupNameAction::RenameRequest(col, req) => { 54 | rename_request_cmd(&mut state.common, CollectionRequest(col, req), name) 55 | .map(|_| Message::Done) 56 | } 57 | PopupNameAction::NewRequest(col, folder) => create_new_request_cmd( 58 | &mut state.common, 59 | col, 60 | folder, 61 | name, 62 | Request::default(), 63 | ) 64 | .map(|_| Message::Done), 65 | PopupNameAction::NewScript(col) => { 66 | create_script_cmd(&mut state.common, col, name).map(|_| Message::Done) 67 | } 68 | PopupNameAction::RenameScript(col, old_name) => { 69 | rename_script_cmd(&mut state.common, col, old_name, name) 70 | .map(|_| Message::Done) 71 | } 72 | PopupNameAction::CreateEnvironment(tab) => { 73 | if let Some(Tab::Collection(tab)) = state.get_tab_mut(tab) { 74 | tab.env_editor.create_env(name); 75 | } 76 | Task::done(Message::Done) 77 | } 78 | PopupNameAction::RenameEnvironment(tab, env_key) => { 79 | if let Some(Tab::Collection(tab)) = state.get_tab_mut(tab) 80 | && let Some(mut env) = tab.env_editor.remove_env(env_key) 81 | { 82 | env.name = name; 83 | tab.env_editor.add_env(env); 84 | } 85 | Task::done(Message::Done) 86 | } 87 | } 88 | } 89 | Message::Done => { 90 | state.common.popup = None; 91 | Task::none() 92 | } 93 | } 94 | } 95 | } 96 | 97 | pub fn title<'a>() -> Cow<'a, str> { 98 | Cow::Borrowed("Enter Name") 99 | } 100 | 101 | pub fn done(data: &PopupNameState) -> Option { 102 | if data.name.is_empty() { 103 | None 104 | } else { 105 | Some(Message::Rename(data.name.clone())) 106 | } 107 | } 108 | 109 | pub(crate) fn view<'a>(_state: &'a AppState, data: &'a PopupNameState) -> Element<'a, Message> { 110 | let name = Row::new() 111 | .push(text("Name")) 112 | .push(space::horizontal()) 113 | .push( 114 | text_input("Name", &data.name) 115 | .on_input(Message::NameChanged) 116 | .on_paste(Message::NameChanged), 117 | ) 118 | .spacing(4); 119 | 120 | Column::new().push(name).spacing(4).width(300).into() 121 | } 122 | -------------------------------------------------------------------------------- /crates/core/src/http/environment.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | sync::Arc, 4 | }; 5 | 6 | use crate::new_id_type; 7 | use parsers::{Token, parse_template}; 8 | 9 | pub type VarMap = HashMap; 10 | 11 | new_id_type! { 12 | pub struct EnvironmentKey; 13 | } 14 | 15 | #[derive(Debug, Clone)] 16 | pub struct Environments { 17 | envs: HashMap, 18 | } 19 | 20 | impl Default for Environments { 21 | fn default() -> Self { 22 | Self::new() 23 | } 24 | } 25 | 26 | impl Environments { 27 | pub fn new() -> Self { 28 | Self { 29 | envs: HashMap::new(), 30 | } 31 | } 32 | 33 | pub fn get(&self, id: EnvironmentKey) -> Option<&Environment> { 34 | self.envs.get(&id) 35 | } 36 | 37 | pub fn get_mut(&mut self, id: &EnvironmentKey) -> Option<&mut Environment> { 38 | self.envs.get_mut(id) 39 | } 40 | 41 | pub fn insert(&mut self, env: Environment) -> EnvironmentKey { 42 | let id = EnvironmentKey::new(); 43 | self.envs.insert(id, env); 44 | id 45 | } 46 | 47 | pub fn update(&mut self, id: EnvironmentKey, env: Environment) { 48 | self.envs.insert(id, env); 49 | } 50 | 51 | pub fn entries(&self) -> impl Iterator { 52 | self.envs.iter() 53 | } 54 | 55 | pub fn is_empty(&self) -> bool { 56 | self.envs.is_empty() 57 | } 58 | 59 | pub fn create(&mut self, name: String) -> EnvironmentKey { 60 | let env = Environment::new(name); 61 | self.insert(env) 62 | } 63 | 64 | pub fn find_by_name(&self, name: &str) -> Option { 65 | self.envs 66 | .iter() 67 | .find(|(_, env)| env.name == name) 68 | .map(|(id, _)| *id) 69 | } 70 | 71 | pub(crate) fn replace_all( 72 | &mut self, 73 | envs: HashMap, 74 | ) -> Vec { 75 | let removed = self 76 | .envs 77 | .extract_if(|key, _| !envs.contains_key(key)) 78 | .map(|(_, env)| env) 79 | .collect(); 80 | 81 | self.envs = envs; 82 | removed 83 | } 84 | } 85 | 86 | #[derive(Debug, Clone)] 87 | pub struct Environment { 88 | pub name: String, 89 | pub variables: Arc>, 90 | } 91 | 92 | impl Environment { 93 | pub fn new(name: String) -> Self { 94 | Self { 95 | name, 96 | variables: Arc::new(HashMap::new()), 97 | } 98 | } 99 | 100 | pub fn get(&self, name: &str) -> Option<&str> { 101 | self.variables.get(name).map(|s| s.as_str()) 102 | } 103 | 104 | pub fn vars(&self) -> Arc { 105 | Arc::clone(&self.variables) 106 | } 107 | } 108 | 109 | #[derive(Debug, Clone, Default)] 110 | pub struct EnvironmentChain { 111 | dotenv: Arc, 112 | vars: Vec>, 113 | } 114 | 115 | impl EnvironmentChain { 116 | pub fn new() -> Self { 117 | Self { 118 | dotenv: Default::default(), 119 | vars: Vec::new(), 120 | } 121 | } 122 | 123 | pub fn from_iter(dotenv: Arc, iter: I) -> Self 124 | where 125 | I: IntoIterator>, 126 | { 127 | Self { 128 | dotenv, 129 | vars: iter.into_iter().collect(), 130 | } 131 | } 132 | 133 | fn get_named(name: &str, vars: &VarMap) -> Option { 134 | vars.get(name).map(|s| s.to_owned()) 135 | } 136 | 137 | pub fn all_var_set(&self) -> Arc> { 138 | let mut set = HashSet::new(); 139 | for vars in self.vars.iter().chain([&self.dotenv]) { 140 | for (name, _) in vars.iter() { 141 | set.insert(name.clone()); 142 | } 143 | } 144 | Arc::new(set) 145 | } 146 | 147 | fn get_from(&self, name: &str, vars: &[Arc]) -> Option { 148 | let name = name.trim_ascii(); 149 | vars.iter() 150 | .find_map(|vars| Self::get_named(name, vars)) 151 | .map(|s| self.replace_with(&s, &[Arc::clone(&self.dotenv)])) 152 | } 153 | 154 | fn replace_with(&self, source: &str, vars: &[Arc]) -> String { 155 | let mut buffer = String::new(); 156 | for span in parse_template(source) { 157 | match span.token { 158 | Token::Text(text) => buffer.push_str(&text), 159 | Token::Variable(var) => { 160 | let value = self.get_from(&var, vars).unwrap_or(var); 161 | buffer.push_str(value.as_str()); 162 | } 163 | Token::Escaped(text) => { 164 | buffer.push_str(&text); 165 | } 166 | } 167 | } 168 | buffer 169 | } 170 | 171 | pub fn replace(&self, source: &str) -> String { 172 | self.replace_with(source, self.vars.as_slice()) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/app/panels/collection/settings.rs: -------------------------------------------------------------------------------- 1 | use lib::http::collection::Collection; 2 | use std::{collections::HashSet, sync::Arc, time::Duration}; 3 | 4 | use crate::components::scrollable; 5 | use crate::components::{KeyValList, KeyValUpdateMsg, key_value_editor, text_input}; 6 | use iced::{ 7 | Alignment, Element, Length, Task, padding, 8 | widget::{Column, Row, pick_list, rule, space, toggler}, 9 | }; 10 | 11 | use crate::{ 12 | commands::builders::save_collection_cmd, 13 | state::tabs::collection_tab::CollectionTab, 14 | state::{AppState, Tab}, 15 | }; 16 | 17 | #[derive(Debug, Clone)] 18 | pub enum Message { 19 | UpdateDefaultEnv(String), 20 | UpdateHeaders(KeyValUpdateMsg), 21 | UpdateVariables(KeyValUpdateMsg), 22 | SaveChanges, 23 | Saved, 24 | DisableSSL(bool), 25 | UpdateTimeout(String), 26 | } 27 | 28 | impl Message { 29 | pub fn update(self, state: &mut AppState) -> Task { 30 | let Some(Tab::Collection(tab)) = state.tabs.get_mut(&state.active_tab) else { 31 | return Task::none(); 32 | }; 33 | let Some(collection) = state.common.collections.get_mut(tab.collection_key) else { 34 | return Task::none(); 35 | }; 36 | 37 | match self { 38 | Message::UpdateDefaultEnv(name) => { 39 | let env = collection.environments.find_by_name(&name); 40 | tab.edited = true; 41 | tab.default_env = Some(name); 42 | collection.set_default_env(env); 43 | } 44 | Message::UpdateHeaders(msg) => { 45 | tab.edited = true; 46 | tab.headers.update(msg); 47 | } 48 | Message::UpdateVariables(_msg) => { 49 | tab.edited = true; 50 | // tab.variables.update(msg); 51 | } 52 | Message::SaveChanges => { 53 | return save_collection_cmd(collection, tab).map(move |_| Message::Saved); 54 | } 55 | Message::DisableSSL(disabled) => { 56 | tab.edited = true; 57 | tab.disable_ssl = disabled; 58 | } 59 | Message::UpdateTimeout(update) => { 60 | if let Ok(millis) = update.parse() { 61 | tab.edited = true; 62 | tab.timeout_str = update; 63 | tab.timeout = Duration::from_millis(millis); 64 | } 65 | } 66 | Message::Saved => (), 67 | }; 68 | 69 | Task::none() 70 | } 71 | } 72 | 73 | pub fn headers_view<'a>(vals: &'a KeyValList, vars: Arc>) -> Element<'a, Message> { 74 | Column::new() 75 | .push(rule::horizontal(2.)) 76 | .push("Collection Headers") 77 | .push(key_value_editor(vals, &vars).on_change(Message::UpdateHeaders)) 78 | .spacing(8) 79 | .width(Length::Fill) 80 | .into() 81 | } 82 | 83 | pub fn view<'a>(tab: &'a CollectionTab, col: &'a Collection) -> Element<'a, Message> { 84 | let environments = &tab.env_editor.environments; 85 | let envs: Vec<_> = environments.values().map(|env| env.name.clone()).collect(); 86 | 87 | let header_vars = col.env_chain().all_var_set(); 88 | let default_env_name = tab.default_env.as_ref(); 89 | 90 | let default_env = Row::new() 91 | .push("Default Environment") 92 | .push(space::horizontal().width(Length::FillPortion(4))) 93 | .push( 94 | pick_list(envs, default_env_name, Message::UpdateDefaultEnv) 95 | .width(Length::FillPortion(1)) 96 | .placeholder("Default Environment"), 97 | ) 98 | .spacing(4) 99 | .width(Length::Fill) 100 | .align_y(Alignment::Center); 101 | 102 | let disable_ssl = Row::new() 103 | .push("Disable SSL Certificate Verification") 104 | .push(space::horizontal().width(Length::FillPortion(4))) 105 | .push( 106 | toggler(tab.disable_ssl) 107 | .on_toggle(Message::DisableSSL) 108 | .size(20), 109 | ) 110 | .spacing(4) 111 | .width(Length::Fill) 112 | .align_y(Alignment::Center); 113 | 114 | let timeout = Row::new() 115 | .push("Default Timeout (ms)") 116 | .push(space::horizontal().width(Length::FillPortion(4))) 117 | .push(text_input( 118 | "Millis", 119 | &tab.timeout_str, 120 | Message::UpdateTimeout, 121 | )) 122 | .spacing(4) 123 | .width(Length::Fill) 124 | .align_y(Alignment::Center); 125 | 126 | scrollable( 127 | Column::new() 128 | .push(default_env) 129 | .push(disable_ssl) 130 | .push(timeout) 131 | .push(space::horizontal().width(Length::Fixed(8.))) 132 | .push(headers_view(&tab.headers, header_vars)) 133 | .spacing(16) 134 | .width(Length::Fill) 135 | .height(Length::Shrink) 136 | .padding(padding::right(12).top(12).bottom(12)), 137 | ) 138 | .width(Length::Fill) 139 | .into() 140 | } 141 | -------------------------------------------------------------------------------- /src/app/panels/cookie_store.rs: -------------------------------------------------------------------------------- 1 | use crate::components::{ 2 | LineEditorMsg, bold, icon, icon_button, icons, line_editor, scrollable, tooltip, 3 | }; 4 | use cookie_store::Cookie; 5 | use iced::widget::text::Wrapping; 6 | use iced::widget::{button, column, container, row, table, text}; 7 | use iced::{Alignment, Element, Length, Task}; 8 | 9 | use crate::state::tabs::cookies_tab::CookiesTab; 10 | use crate::state::{AppState, Tab}; 11 | 12 | #[derive(Debug, Clone)] 13 | pub enum CookieTabMsg { 14 | DeleteCookie(String, String, String), 15 | ClearAllCookies, 16 | SearchChanged(LineEditorMsg), 17 | ClearSearch, 18 | } 19 | 20 | impl CookieTabMsg { 21 | pub fn update(self, state: &mut AppState) -> Task { 22 | let Some(Tab::CookieStore(tab)) = state.active_tab_mut() else { 23 | return Task::none(); 24 | }; 25 | match self { 26 | CookieTabMsg::DeleteCookie(name, domain, path) => { 27 | tab.delete_cookie(&name, &domain, &path); 28 | Task::none() 29 | } 30 | CookieTabMsg::ClearAllCookies => { 31 | tab.clear_all(); 32 | Task::none() 33 | } 34 | CookieTabMsg::SearchChanged(update) => { 35 | update.update(&mut tab.search_query); 36 | let query = tab.search_query.text().trim().to_string(); 37 | tab.set_search_query(&query); 38 | Task::none() 39 | } 40 | CookieTabMsg::ClearSearch => { 41 | tab.clear_search_query(); 42 | Task::none() 43 | } 44 | } 45 | } 46 | } 47 | 48 | pub fn view<'a>(tab: &'a CookiesTab) -> Element<'a, CookieTabMsg> { 49 | let is_empty = tab.search_query_text.is_empty(); 50 | let cookies = tab.cookies(); 51 | 52 | let search_placeholder = "Search (name, value, domain)..."; 53 | 54 | let search_input = container( 55 | line_editor(&tab.search_query) 56 | .placeholder(search_placeholder) 57 | .highlight(false) 58 | .map(CookieTabMsg::SearchChanged), 59 | ) 60 | .width(Length::FillPortion(1)); 61 | 62 | let clear_all_button = icon_button(icons::Delete, Some(24), Some(8)) 63 | .style(button::danger) 64 | .on_press_maybe((!cookies.is_empty()).then_some(CookieTabMsg::ClearAllCookies)); 65 | 66 | let clear_all_button = tooltip("Remove all cookies", clear_all_button); 67 | 68 | let search_row = row![search_input, clear_all_button] 69 | .align_y(Alignment::Center) 70 | .spacing(8); 71 | 72 | let columns = [ 73 | table::column(bold("Name"), |cookie: Cookie<'static>| { 74 | text(cookie.name().to_string()) 75 | }) 76 | .width(Length::FillPortion(1)) 77 | .align_y(Alignment::Center), 78 | table::column(bold("Value"), |cookie: Cookie<'static>| { 79 | text(cookie.value().to_string()).wrapping(Wrapping::Glyph) 80 | }) 81 | .width(Length::FillPortion(2)) 82 | .align_y(Alignment::Center), 83 | table::column(bold("Domain"), |cookie: Cookie<'static>| { 84 | text(cookie.domain().unwrap_or_default().to_string()) 85 | }) 86 | .width(Length::FillPortion(1)) 87 | .align_y(Alignment::Center), 88 | table::column(bold("Path"), |cookie: Cookie<'static>| { 89 | text(cookie.path().unwrap_or_default().to_string()) 90 | }) 91 | .align_y(Alignment::Center), 92 | table::column(bold("Secure"), |cookie: Cookie<'static>| { 93 | text( 94 | cookie 95 | .secure() 96 | .map(|s| if s { "Secure" } else { "Insecure" }) 97 | .unwrap_or_default() 98 | .to_string(), 99 | ) 100 | }) 101 | .align_y(Alignment::Center), 102 | table::column(text(""), |cookie: Cookie<'static>| { 103 | let name = cookie.name().to_string(); 104 | let domain = cookie.domain().unwrap_or_default().to_string(); 105 | let path = cookie.path().unwrap_or_default().to_string(); 106 | 107 | tooltip( 108 | "Delete cookie", 109 | button(icon(icons::Delete).size(20)) 110 | .padding([0, 4]) 111 | .style(button::text) 112 | .on_press(CookieTabMsg::DeleteCookie(name, domain, path)), 113 | ) 114 | }) 115 | .align_x(Alignment::Center) 116 | .align_y(Alignment::Center), 117 | ]; 118 | 119 | let content: Element<'a, CookieTabMsg> = if cookies.is_empty() { 120 | let message = if is_empty { 121 | "No cookies found" 122 | } else { 123 | "No matching cookies found" 124 | }; 125 | text(message).into() 126 | } else { 127 | container(scrollable( 128 | table(columns, cookies.to_vec()).padding_x(8).padding_y(4), 129 | )) 130 | .style(container::bordered_box) 131 | .into() 132 | }; 133 | 134 | column![search_row, content] 135 | .spacing(8) 136 | .width(Length::Fill) 137 | .height(Length::Fill) 138 | .into() 139 | } 140 | -------------------------------------------------------------------------------- /src/components/editor/undo_stack.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use iced_core::text::Editor; 4 | use iced_core::text::editor::{Action, Edit}; 5 | 6 | type Cursor = (usize, usize); 7 | 8 | pub(crate) struct EditorAction { 9 | pub pre_cursor: Cursor, 10 | pub post_cursor: Cursor, 11 | pub pre_selection: Option, 12 | pub post_selection: Option, 13 | pub char_at_cursor: Option, 14 | pub char_after_cursor: Option, 15 | pub pre_selection_text: Option>, 16 | pub edit: Edit, 17 | } 18 | 19 | pub struct UndoStack { 20 | stack: Vec, 21 | current_index: usize, 22 | } 23 | 24 | impl UndoStack { 25 | pub fn new() -> Self { 26 | Self { 27 | stack: Vec::new(), 28 | current_index: 0, 29 | } 30 | } 31 | 32 | pub fn push(&mut self, action: EditorAction) { 33 | self.stack.truncate(self.current_index); 34 | self.stack.push(action); 35 | self.current_index += 1; 36 | } 37 | 38 | pub fn undo(&mut self, content: &mut impl Editor) { 39 | if self.current_index == 0 { 40 | return; 41 | } 42 | let actions = self.stack[0..self.current_index].iter().enumerate().rev(); 43 | 44 | let mut insert = false; 45 | for (index, action) in actions { 46 | if insert && !matches!(action.edit, Edit::Insert(_)) { 47 | break; 48 | } 49 | self.current_index = index; 50 | 51 | restore_selection(content, action.post_cursor, action.post_selection); 52 | match &action.edit { 53 | Edit::Insert(_) => { 54 | insert = true; 55 | content.perform(Action::Edit(Edit::Backspace)); 56 | paste_prev_selection(action, content); 57 | continue; 58 | } 59 | Edit::Paste(_) => { 60 | let cursor = if let Some(selection) = action.pre_selection { 61 | selection.min(action.pre_cursor) 62 | } else { 63 | action.pre_cursor 64 | }; 65 | content.perform(Action::Cursor(cursor.0, cursor.1)); 66 | content.perform(Action::SelectTo(action.post_cursor.0, action.post_cursor.1)); 67 | content.perform(Action::Edit(Edit::Delete)); 68 | 69 | paste_prev_selection(action, content); 70 | } 71 | Edit::Enter => { 72 | content.perform(Action::Edit(Edit::Backspace)); 73 | paste_prev_selection(action, content); 74 | } 75 | Edit::Indent => {} 76 | Edit::Unindent => {} 77 | edit @ (Edit::Backspace | Edit::Delete) => { 78 | let char = match edit { 79 | Edit::Backspace => action.char_at_cursor, 80 | Edit::Delete => action.char_after_cursor, 81 | _ => None, 82 | }; 83 | if !paste_prev_selection(action, content) 84 | && let Some(char) = char 85 | { 86 | content.perform(Action::Edit(Edit::Insert(char))); 87 | } 88 | } 89 | } 90 | restore_selection(content, action.pre_cursor, action.pre_selection); 91 | break; 92 | } 93 | } 94 | 95 | pub fn redo(&mut self, content: &mut impl Editor) { 96 | if self.current_index == self.stack.len() { 97 | return; 98 | } 99 | let actions = &self.stack[self.current_index..]; 100 | let mut insert = false; 101 | 102 | for action in actions.iter() { 103 | if insert && !matches!(action.edit, Edit::Insert(_)) { 104 | break; 105 | } 106 | self.current_index += 1; 107 | 108 | restore_selection(content, action.pre_cursor, action.pre_selection); 109 | match &action.edit { 110 | Edit::Insert(_) => { 111 | insert = true; 112 | content.perform(Action::Edit(action.edit.clone())); 113 | continue; 114 | } 115 | Edit::Paste(_) | Edit::Enter | Edit::Delete | Edit::Backspace => { 116 | content.perform(Action::Edit(action.edit.clone())); 117 | } 118 | Edit::Indent => {} 119 | Edit::Unindent => {} 120 | } 121 | break; 122 | } 123 | } 124 | } 125 | 126 | fn restore_selection(content: &mut impl Editor, cursor: Cursor, selection: Option) { 127 | if let Some((line, col)) = selection { 128 | content.perform(Action::Cursor(line, col)); 129 | content.perform(Action::SelectTo(cursor.0, cursor.1)); 130 | } else { 131 | content.perform(Action::Cursor(cursor.0, cursor.1)); 132 | } 133 | } 134 | 135 | fn paste_prev_selection(action: &EditorAction, content: &mut impl Editor) -> bool { 136 | if let Some(selection) = &action.pre_selection_text { 137 | content.perform(Action::Edit(Edit::Paste(Arc::clone(selection)))); 138 | restore_selection(content, action.pre_cursor, action.pre_selection); 139 | true 140 | } else { 141 | false 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 |
2 | logo 3 | 4 | [![Discord](https://img.shields.io/discord/1261282563138392117?color=5865F2&label=Discord&logo=discord&logoColor=white)](https://discord.gg/FSK25BXgdt) 5 | 6 |
7 | 8 | # Sanchaar - A Offline REST API client 9 | 10 | Sanchaar is a offline REST API client built using Iced in Rust. It is a simple tool to test REST APIs without the need of internet connection. It supports GET, POST, PUT, DELETE requests with path, query and header parameters. 11 | 12 | ## Screenshot 13 | 14 | ![Screenshot](./screenshots/app.png) 15 | 16 | ## Installation 17 | 18 | ### macOS 19 | 20 | #### Homebrew (Recommended) 21 | 22 | Install using Homebrew Cask with automatic quarantine removal: 23 | 24 | ```bash 25 | brew tap nrjais/tap 26 | brew install --cask sanchaar 27 | ``` 28 | 29 | Or in a single command: 30 | 31 | ```bash 32 | brew install --cask nrjais/tap/sanchaar 33 | ``` 34 | 35 | #### Manual Installation 36 | 37 | Download the latest release from the [Releases page](https://github.com/nrjais/sanchaar/releases). 38 | 39 | **Important:** Since the app is not notarized by Apple, you'll need to remove the quarantine attribute after downloading: 40 | 41 | 1. Download the `.dmg` file for your architecture (Intel or Apple Silicon) 42 | 2. Open the DMG and drag Sanchaar to Applications 43 | 3. Remove the quarantine attribute: 44 | ```bash 45 | xattr -cr /Applications/Sanchaar.app 46 | ``` 47 | 48 | #### Linux Installation 49 | 50 | **x86_64 (64-bit Intel/AMD):** 51 | - **Debian/Ubuntu** (.deb): `sudo dpkg -i Sanchaar-*.deb && sudo apt-get install -f` 52 | 53 | **ARM64 (aarch64):** 54 | - **Archive** (.tar.gz): Extract and run the binary 55 | 56 | ## Features 57 | 58 | - Send GET, POST, PUT, DELETE requests 59 | - Path, Query, Header params 60 | - Multiple requests in tabs 61 | - Save/Load requests from collections 62 | - Create/Open collection from local disk 63 | - Environment variables 64 | 65 | ## Roadmap 66 | 67 | - [x] Path param support 68 | - [x] Query param support 69 | - [x] Header param support 70 | - [x] Body support 71 | - [x] JSON 72 | - [x] Form 73 | - [x] XML 74 | - [x] Text 75 | - [x] Raw File 76 | - [x] Multipart (Files not supported with GET method) 77 | - [x] Request cancellation 78 | - [ ] Authentication 79 | - [x] Basic 80 | - [x] Bearer 81 | - [x] API key 82 | - [x] JWT 83 | - [ ] OAuth 84 | - [ ] OAuth2 85 | - [ ] AWS 86 | - [ ] Digest Auth 87 | - [x] Tab view for multiple requests 88 | - [x] File persistence 89 | - [x] TOML file format 90 | - [x] Save/Load 91 | - [x] Changed indicator 92 | - [x] File Rename 93 | - [ ] Collections/Folder 94 | - [x] Tree view 95 | - [x] Create/Open 96 | - [x] Auto Save 97 | - [ ] Refresh tree manually 98 | - [ ] Refresh tree automatically 99 | - [x] Remove 100 | - [x] Rename collection/folder 101 | - [ ] Export/Import 102 | - [x] Import Postman collections 103 | - [ ] Settings 104 | - [x] Update default env 105 | - [x] Collection headers 106 | - [ ] Collection Variables 107 | - [x] SSL verification 108 | - [ ] Collection auth 109 | - [ ] Request preset 110 | - [x] Timeout 111 | - [ ] Environments 112 | - [x] Add/Remove/Update 113 | - [x] Choose environment 114 | - [x] Auto Save/Load current state 115 | - [ ] Secure environment variables (keyring) 116 | - [x] Variables from .env file 117 | - [x] dotenv file var access in environment vars 118 | - [ ] Assertions 119 | - [x] Status code 120 | - [x] Response time 121 | - [x] Response body 122 | - [x] Response headers 123 | - [ ] GUI editor/viewer 124 | - [ ] Scripting 125 | - [ ] Pre request 126 | - [ ] Post request 127 | - [ ] Settings 128 | - [x] Theme 129 | - [ ] Cookie store toggle 130 | - [ ] Proxy 131 | - [ ] About 132 | - [ ] Cookies 133 | - [x] List/Remove 134 | - [ ] Edit/Add ? 135 | - [x] History 136 | - [x] List 137 | - [x] Clear 138 | - [x] Open from history 139 | - [ ] Mock APIs 140 | - [ ] CLI 141 | - [x] Run request by path 142 | - [x] Run assertion by path/folder 143 | - [x] Pretty print assertion results 144 | - [ ] Select environment by name 145 | - [x] Run tests by path 146 | - [ ] Run all collection tests 147 | - [x] Import Postman collections 148 | - [ ] Code export 149 | - [ ] Body Viewer improvements 150 | - [x] Json path filter 151 | - [ ] XML path filter 152 | - [ ] Search in body 153 | - [x] Download body 154 | - [ ] Body Editor 155 | - [x] Prettify body JSON 156 | - [ ] Search in body 157 | - [x] Hotkeys 158 | - [x] Close tab (Cmd + W) 159 | - [x] Close all tabs (Cmd + Shift + W) 160 | - [x] Close other tabs (Cmd + Alt + W) 161 | - [x] New request (Cmd + T) 162 | - [x] Send request (Cmd + Enter) 163 | - [x] Save request (Cmd + S - In request view) 164 | - [x] Save collection (Cmd + S - In collection view) 165 | - [x] Save environment (Cmd + S - In environment view) 166 | - [x] App Setting (Cmd + ,) 167 | - [x] Collection Setting (Cmd + ; - In http tab) 168 | - [ ] Other improvements 169 | - [ ] Error handling 170 | - [ ] Logging and tracing request 171 | - [ ] Reduce clones / Performance 172 | - [x] Variable highlighting 173 | - [x] Session persistence 174 | 175 | 176 | ## Star History 177 | 178 | 179 | 180 | 181 | 182 | Star History Chart 183 | 184 | 185 | -------------------------------------------------------------------------------- /src/components/min_dimension.rs: -------------------------------------------------------------------------------- 1 | //! A widget that uses a two pass layout. 2 | //! 3 | //! Layout from first pass is used to set the limits for the second pass 4 | 5 | use iced::advanced::widget::tree; 6 | use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget}; 7 | use iced::{Element, Event, Length, Rectangle, Renderer, Size, Theme, Vector, mouse}; 8 | use iced_core::widget::{Operation, Tree}; 9 | 10 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 11 | enum Dimension { 12 | Width, 13 | Height, 14 | } 15 | 16 | pub fn min_width<'a, Message>( 17 | first_pass: impl Into>, 18 | second_pass: impl Into>, 19 | min_width: f32, 20 | ) -> MinDimension<'a, Message> 21 | where 22 | Message: 'a, 23 | { 24 | MinDimension { 25 | first_pass: first_pass.into(), 26 | second_pass: second_pass.into(), 27 | min: min_width, 28 | dimension: Dimension::Width, 29 | } 30 | } 31 | 32 | pub fn min_height<'a, Message>( 33 | first_pass: impl Into>, 34 | second_pass: impl Into>, 35 | min_height: f32, 36 | ) -> MinDimension<'a, Message> 37 | where 38 | Message: 'a, 39 | { 40 | MinDimension { 41 | first_pass: first_pass.into(), 42 | second_pass: second_pass.into(), 43 | min: min_height, 44 | dimension: Dimension::Height, 45 | } 46 | } 47 | 48 | pub struct MinDimension<'a, Message> { 49 | first_pass: Element<'a, Message>, 50 | second_pass: Element<'a, Message>, 51 | min: f32, 52 | dimension: Dimension, 53 | } 54 | 55 | impl<'a, Message> Widget for MinDimension<'a, Message> { 56 | fn size(&self) -> Size { 57 | self.second_pass.as_widget().size() 58 | } 59 | 60 | fn size_hint(&self) -> Size { 61 | self.second_pass.as_widget().size_hint() 62 | } 63 | 64 | fn layout( 65 | &mut self, 66 | tree: &mut widget::Tree, 67 | renderer: &Renderer, 68 | limits: &layout::Limits, 69 | ) -> layout::Node { 70 | let layout = self 71 | .first_pass 72 | .as_widget_mut() 73 | .layout(tree, renderer, limits); 74 | 75 | let bounds = layout.bounds(); 76 | 77 | let new_limits = if self.dimension == Dimension::Width { 78 | let size = Size::new(self.min.max(bounds.width), bounds.height); 79 | layout::Limits::new( 80 | Size::ZERO, 81 | size.expand(Size::new(horizontal_expansion(), 1.0)), 82 | ) 83 | } else { 84 | let size = Size::new(bounds.width, self.min.max(bounds.height)); 85 | layout::Limits::new(Size::ZERO, size.expand(Size::new(1.0, 1.0))) 86 | }; 87 | 88 | self.second_pass 89 | .as_widget_mut() 90 | .layout(tree, renderer, &new_limits) 91 | } 92 | 93 | fn draw( 94 | &self, 95 | tree: &widget::Tree, 96 | renderer: &mut Renderer, 97 | theme: &Theme, 98 | style: &renderer::Style, 99 | layout: Layout<'_>, 100 | cursor: mouse::Cursor, 101 | viewport: &Rectangle, 102 | ) { 103 | self.second_pass 104 | .as_widget() 105 | .draw(tree, renderer, theme, style, layout, cursor, viewport) 106 | } 107 | 108 | fn tag(&self) -> tree::Tag { 109 | self.second_pass.as_widget().tag() 110 | } 111 | 112 | fn state(&self) -> tree::State { 113 | self.second_pass.as_widget().state() 114 | } 115 | 116 | fn children(&self) -> Vec { 117 | self.second_pass.as_widget().children() 118 | } 119 | 120 | fn diff(&self, tree: &mut widget::Tree) { 121 | self.second_pass.as_widget().diff(tree); 122 | } 123 | 124 | fn operate( 125 | &mut self, 126 | tree: &mut iced::advanced::widget::Tree, 127 | layout: Layout<'_>, 128 | renderer: &Renderer, 129 | operation: &mut dyn Operation<()>, 130 | ) { 131 | self.second_pass 132 | .as_widget_mut() 133 | .operate(tree, layout, renderer, operation); 134 | } 135 | 136 | fn update( 137 | &mut self, 138 | tree: &mut widget::Tree, 139 | event: &Event, 140 | layout: Layout<'_>, 141 | cursor: mouse::Cursor, 142 | renderer: &Renderer, 143 | clipboard: &mut dyn Clipboard, 144 | shell: &mut Shell<'_, Message>, 145 | viewport: &Rectangle, 146 | ) { 147 | self.second_pass.as_widget_mut().update( 148 | tree, event, layout, cursor, renderer, clipboard, shell, viewport, 149 | ) 150 | } 151 | 152 | fn mouse_interaction( 153 | &self, 154 | tree: &widget::Tree, 155 | layout: Layout<'_>, 156 | cursor: mouse::Cursor, 157 | viewport: &Rectangle, 158 | renderer: &Renderer, 159 | ) -> mouse::Interaction { 160 | self.second_pass 161 | .as_widget() 162 | .mouse_interaction(tree, layout, cursor, viewport, renderer) 163 | } 164 | 165 | fn overlay<'b>( 166 | &'b mut self, 167 | tree: &'b mut Tree, 168 | layout: Layout<'b>, 169 | renderer: &Renderer, 170 | viewport: &Rectangle, 171 | translation: Vector, 172 | ) -> Option> { 173 | self.second_pass 174 | .as_widget_mut() 175 | .overlay(tree, layout, renderer, viewport, translation) 176 | } 177 | } 178 | 179 | impl<'a, Message> From> for Element<'a, Message> 180 | where 181 | Message: 'a, 182 | { 183 | fn from(double_pass: MinDimension<'a, Message>) -> Self { 184 | Element::new(double_pass) 185 | } 186 | } 187 | 188 | fn horizontal_expansion() -> f32 { 189 | 1.0 190 | } 191 | -------------------------------------------------------------------------------- /src/components/script_view.rs: -------------------------------------------------------------------------------- 1 | use iced::widget::{Column, Row, button, container, pick_list, text}; 2 | use iced::{Element, Length}; 3 | use lib::http::collection::Script; 4 | 5 | use crate::components::editor::Content; 6 | use crate::components::scrollable; 7 | use crate::components::{ContentType, code_editor, icon_button, icons, tooltip}; 8 | 9 | /// Configuration for script view behavior 10 | #[derive(Default)] 11 | pub struct ScriptViewConfig { 12 | pub editable: bool, 13 | pub show_create_button: bool, 14 | pub enable_type_tabs: bool, 15 | } 16 | 17 | /// Renders a script selector dropdown with optional create/remove/save buttons 18 | pub fn script_selector<'a, Message: 'a + Clone>( 19 | scripts: &'a [Script], 20 | selected: Option<&'a String>, 21 | on_select: impl Fn(String) -> Message + 'a, 22 | on_deselect: Option, 23 | on_create: Option, 24 | on_save: Option<(bool, Message)>, // (is_edited, save_message) 25 | ) -> Element<'a, Message> { 26 | let picker = pick_list( 27 | scripts.iter().map(|s| s.name.clone()).collect::>(), 28 | selected, 29 | on_select, 30 | ) 31 | .placeholder("Select Script") 32 | .width(Length::Fill) 33 | .padding([2, 8]) 34 | .text_size(14); 35 | 36 | let mut selector_row = Row::new() 37 | .push(text("Script:").width(Length::Shrink)) 38 | .push(picker) 39 | .spacing(12) 40 | .align_y(iced::Alignment::Center); 41 | 42 | // Add save button if provided 43 | if let Some((is_edited, save_msg)) = on_save { 44 | let save_button = if is_edited { 45 | button("Save Script") 46 | .padding([6, 16]) 47 | .on_press(save_msg) 48 | .style(button::primary) 49 | } else { 50 | button("Saved").padding([6, 16]).style(button::secondary) 51 | }; 52 | selector_row = selector_row.push(save_button); 53 | } 54 | 55 | // Add action buttons to the same row 56 | if let Some(create_msg) = on_create { 57 | selector_row = selector_row.push(tooltip( 58 | "New Script", 59 | icon_button(icons::Plus, Some(18), Some(8)) 60 | .on_press(create_msg) 61 | .style(button::secondary), 62 | )); 63 | } 64 | 65 | if let Some(deselect_msg) = on_deselect { 66 | selector_row = selector_row.push(tooltip( 67 | "Remove Script", 68 | icon_button(icons::Close, Some(18), Some(8)) 69 | .on_press_maybe(selected.map(|_| deselect_msg)) 70 | .style(button::secondary), 71 | )); 72 | } 73 | 74 | selector_row = selector_row.spacing(8); 75 | 76 | Column::new().push(selector_row).into() 77 | } 78 | 79 | /// Renders the script editor without header bar 80 | pub fn script_editor<'a, Message: 'a + Clone>( 81 | script_content: &'a Content, 82 | editable: bool, 83 | on_edit: impl Fn(crate::components::CodeEditorMsg) -> Message + 'a, 84 | ) -> Element<'a, Message> { 85 | let editor = if editable { 86 | code_editor(script_content, ContentType::JS) 87 | .editable() 88 | .map(on_edit) 89 | } else { 90 | code_editor(script_content, ContentType::JS).map(on_edit) 91 | }; 92 | 93 | container(scrollable(editor)) 94 | .padding(12) 95 | .width(Length::Fill) 96 | .height(Length::Fill) 97 | .style(container::rounded_box) 98 | .into() 99 | } 100 | 101 | /// Renders a placeholder message when no script is selected or when loading 102 | pub fn script_placeholder<'a, Message: 'a>(message: &'a str) -> Element<'a, Message> { 103 | container(text(message).size(14)) 104 | .padding(12) 105 | .center(Length::Fill) 106 | .into() 107 | } 108 | 109 | /// Simple script list view for collection panel (read-only) 110 | pub fn script_list_view<'a, Message: 'a + Clone>( 111 | scripts: &'a [Script], 112 | selected: Option<&'a String>, 113 | script_content: Option<&'a Content>, 114 | on_select: impl Fn(Option) -> Message + 'a, 115 | on_edit: impl Fn(crate::components::CodeEditorMsg) -> Message + 'a, 116 | ) -> Element<'a, Message> { 117 | if scripts.is_empty() { 118 | return script_placeholder("No scripts in this collection"); 119 | } 120 | 121 | let script_list = Column::new() 122 | .push(text("Scripts:").size(16)) 123 | .push( 124 | Column::with_children( 125 | scripts 126 | .iter() 127 | .map(|script| { 128 | let is_selected = selected.map(|s| s == &script.name).unwrap_or(false); 129 | let btn_style = if is_selected { 130 | button::primary 131 | } else { 132 | button::secondary 133 | }; 134 | 135 | button(text(&script.name).size(14)) 136 | .width(Length::Fill) 137 | .padding([6, 12]) 138 | .style(btn_style) 139 | .on_press(on_select(Some(script.name.clone()))) 140 | .into() 141 | }) 142 | .collect::>(), 143 | ) 144 | .spacing(4), 145 | ) 146 | .spacing(8) 147 | .padding(12) 148 | .width(Length::FillPortion(1)); 149 | 150 | let content_viewer: Element<'a, Message> = if selected.is_some() { 151 | if let Some(content) = script_content { 152 | script_editor(content, false, on_edit) 153 | } else { 154 | script_placeholder("Loading script...") 155 | } 156 | } else { 157 | script_placeholder("Select a script to view its content") 158 | }; 159 | 160 | let content_pane = Column::new() 161 | .push(content_viewer) 162 | .width(Length::FillPortion(3)) 163 | .height(Length::Fill); 164 | 165 | Row::new() 166 | .push(script_list) 167 | .push(content_pane) 168 | .spacing(8) 169 | .width(Length::Fill) 170 | .height(Length::Fill) 171 | .into() 172 | } 173 | -------------------------------------------------------------------------------- /src/hotkeys.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Task, 3 | advanced::widget::{operate, operation::focusable::focus}, 4 | event::{Status, listen_with}, 5 | keyboard::{self, Event, Key, key::Named}, 6 | mouse, 7 | widget::operation::focus_previous, 8 | }; 9 | 10 | use crate::{ 11 | app::AppMsg, 12 | commands::builders::{ 13 | ResponseResult, save_collection_cmd, save_environments_cmd, save_request_cmd, 14 | send_request_cmd, 15 | }, 16 | state::{AppState, HttpTab, Tab, TabKey, popups::Popup, tabs::collection_tab::CollectionTab}, 17 | }; 18 | 19 | #[derive(Debug, Clone)] 20 | pub enum Message { 21 | Event(iced::Event), 22 | RequestResult(TabKey, ResponseResult), 23 | Done, 24 | } 25 | 26 | impl Message { 27 | pub fn update(self, state: &mut AppState) -> Task { 28 | match self { 29 | Message::Event(e) => { 30 | if let iced::Event::Keyboard(Event::KeyPressed { key, modifiers, .. }) = e { 31 | let key = key.as_ref(); 32 | return handle_hotkeys(key, modifiers, state); 33 | } 34 | } 35 | Message::RequestResult(key, res) => { 36 | if let Some(Tab::Http(tab)) = state.tabs.get_mut(&key) { 37 | tab.update_response(res); 38 | } 39 | } 40 | Message::Done => (), 41 | } 42 | 43 | Task::none() 44 | } 45 | } 46 | 47 | fn handle_hotkeys( 48 | key: Key<&str>, 49 | modifiers: keyboard::Modifiers, 50 | state: &mut AppState, 51 | ) -> Task { 52 | if state.common.popup.is_some() { 53 | if key == Key::Named(Named::Escape) { 54 | Popup::close(&mut state.common); 55 | } 56 | return Task::none(); 57 | } 58 | 59 | if !modifiers.command() { 60 | return Task::none(); 61 | } 62 | 63 | match key { 64 | Key::Character(c) => char_hotkeys(c, modifiers, state), 65 | Key::Named(Named::Enter) => { 66 | let key = state.active_tab; 67 | let Some(tab) = state.tabs.get_mut(&key) else { 68 | return Task::none(); 69 | }; 70 | 71 | if let Tab::Http(tab) = tab { 72 | let cb = move |r| Message::RequestResult(key, r); 73 | send_request_cmd(&mut state.common, tab).map(cb) 74 | } else { 75 | Task::none() 76 | } 77 | } 78 | Key::Named(Named::Escape) => { 79 | if state.common.popup.is_some() { 80 | Popup::close(&mut state.common); 81 | return Task::none(); 82 | } 83 | focus_previous() 84 | } 85 | _ => Task::none(), 86 | } 87 | } 88 | 89 | fn char_hotkeys(c: &str, modifiers: keyboard::Modifiers, state: &mut AppState) -> Task { 90 | match c { 91 | "t" if !modifiers.shift() => { 92 | state.open_tab(Tab::Http(HttpTab::new_def())); 93 | Task::none() 94 | } 95 | "w" if !modifiers.shift() => { 96 | state.close_tab(state.active_tab); 97 | Task::none() 98 | } 99 | "w" if modifiers.shift() => { 100 | state.close_all_tabs(); 101 | Task::none() 102 | } 103 | "," if !modifiers.shift() => { 104 | if state.common.popup.is_none() { 105 | Popup::app_settings(&mut state.common); 106 | } 107 | Task::none() 108 | } 109 | ";" if !modifiers.shift() => { 110 | if let Some(Tab::Http(tab)) = state.active_tab() { 111 | let key = tab.collection_key(); 112 | let collection = state.common.collections.get(key); 113 | if let Some(collection) = collection { 114 | state.open_tab(Tab::Collection(CollectionTab::new(key, collection))); 115 | } 116 | } 117 | Task::none() 118 | } 119 | 120 | "s" if !modifiers.shift() => save_tab(state), 121 | "l" if !modifiers.shift() => { 122 | if let Some(Tab::Http(tab)) = state.active_tab() { 123 | return operate(focus(tab.request().url_id.clone())); 124 | } 125 | Task::none() 126 | } 127 | _ => Task::none(), 128 | } 129 | } 130 | 131 | fn save_tab(state: &mut AppState) -> Task { 132 | let key = state.active_tab; 133 | let Some(tab) = state.tabs.get_mut(&key) else { 134 | return Task::none(); 135 | }; 136 | 137 | match tab { 138 | Tab::Http(tab) => { 139 | let req_ref = state 140 | .common 141 | .collections 142 | .get_ref(tab.collection_ref) 143 | .cloned(); 144 | req_ref 145 | .map(|req| save_request_cmd(tab, req.path).map(|_| Message::Done)) 146 | .unwrap_or_else(|| { 147 | Popup::save_request(&mut state.common, key); 148 | Task::none() 149 | }) 150 | } 151 | Tab::Collection(tab) => { 152 | let mut collection = state.common.collections.get_mut(tab.collection_key); 153 | let save_collection = collection 154 | .as_mut() 155 | .map(|c| save_collection_cmd(c, tab)) 156 | .unwrap_or(Task::none()); 157 | 158 | let save_environments = collection 159 | .as_mut() 160 | .map(|c| save_environments_cmd(c, tab.env_editor.get_envs_for_save())) 161 | .unwrap_or(Task::none()); 162 | 163 | Task::batch([save_collection, save_environments]).map(move |_| Message::Done) 164 | } 165 | Tab::CookieStore(_) => Task::none(), 166 | Tab::History(_) => Task::none(), 167 | Tab::Perf(_) => Task::none(), 168 | } 169 | } 170 | 171 | pub fn subscription(_: &AppState) -> iced::Subscription { 172 | listen_with(|event, status, _window| match status { 173 | Status::Ignored => match event { 174 | iced::Event::Mouse(mouse::Event::CursorMoved { .. }) => None, 175 | _ => Some(event), 176 | }, 177 | Status::Captured => None, 178 | }) 179 | .map(Message::Event) 180 | .map(AppMsg::Subscription) 181 | } 182 | -------------------------------------------------------------------------------- /src/app/popups/save_request.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::ops::Not; 3 | 4 | use iced::widget::{Column, Row, button, container, space, text, text_input}; 5 | use iced::{Element, Length, Task}; 6 | 7 | use crate::commands::builders::save_tab_request_cmd; 8 | use crate::components::scrollable; 9 | use crate::components::{icon, icons}; 10 | use crate::state::AppState; 11 | use crate::state::popups::{Popup, SaveRequestState}; 12 | use lib::http::CollectionKey; 13 | use lib::http::collection::{Collection, Entry, Folder, FolderId}; 14 | 15 | #[derive(Debug, Clone)] 16 | pub enum Message { 17 | Done(CollectionKey), 18 | NameChanged(String), 19 | SelectDir(FolderId), 20 | SelectCollection(CollectionKey), 21 | ClearSelection, 22 | ClearDirectory, 23 | Close, 24 | } 25 | 26 | impl Message { 27 | pub fn update(self, state: &mut AppState) -> Task { 28 | let common = &mut state.common; 29 | let Some(Popup::SaveRequest(ref mut data)) = common.popup else { 30 | return Task::none(); 31 | }; 32 | 33 | match self { 34 | Message::Done(col) => { 35 | let name = data.name.clone(); 36 | let tab = data.tab; 37 | let folder = data.folder_id; 38 | save_tab_request_cmd(state, name, tab, col, folder).map(|_| Message::Close) 39 | } 40 | Message::NameChanged(name) => { 41 | data.name = name; 42 | Task::none() 43 | } 44 | Message::SelectDir(folder) => { 45 | data.folder_id = Some(folder); 46 | Task::none() 47 | } 48 | Message::ClearSelection => { 49 | data.col = None; 50 | data.folder_id = None; 51 | Task::none() 52 | } 53 | Message::ClearDirectory => { 54 | data.folder_id = None; 55 | Task::none() 56 | } 57 | Message::SelectCollection(col) => { 58 | if data.col != Some(col) { 59 | data.col = Some(col); 60 | data.folder_id = None; 61 | } 62 | Task::none() 63 | } 64 | Message::Close => { 65 | common.popup = None; 66 | Task::none() 67 | } 68 | } 69 | } 70 | } 71 | 72 | pub fn title<'a>() -> Cow<'a, str> { 73 | Cow::Borrowed("Save Request") 74 | } 75 | 76 | pub fn done(data: &SaveRequestState) -> Option { 77 | if let Some(col) = data.col { 78 | data.name.is_empty().not().then_some(Message::Done(col)) 79 | } else { 80 | None 81 | } 82 | } 83 | 84 | pub fn col_selector<'a>(state: &'a AppState) -> Element<'a, Message> { 85 | let collections = state 86 | .common 87 | .collections 88 | .iter() 89 | .map(|(k, c)| { 90 | let name = c.name.as_str(); 91 | button(text(name)) 92 | .on_press(Message::SelectCollection(k)) 93 | .style(button::subtle) 94 | .width(Length::Fill) 95 | .padding([2, 4]) 96 | .into() 97 | }) 98 | .collect(); 99 | 100 | Column::from_vec(collections) 101 | .align_x(iced::Alignment::Center) 102 | .width(Length::Fill) 103 | .into() 104 | } 105 | 106 | pub fn dir_selector<'a>( 107 | collection: &'a Collection, 108 | folder: Option<&'a Folder>, 109 | ) -> Element<'a, Message> { 110 | let children = match folder { 111 | Some(folder) => &folder.entries, 112 | _ => &collection.entries, 113 | }; 114 | 115 | let entries: Vec> = children 116 | .iter() 117 | .map(|e| match e { 118 | Entry::Folder(Folder { id, name, .. }) => button(text(name)) 119 | .padding([2, 4]) 120 | .style(button::subtle) 121 | .on_press(Message::SelectDir(*id)) 122 | .width(Length::Fill) 123 | .into(), 124 | Entry::Item(item) => Row::new() 125 | .push(icon(icons::API)) 126 | .push(text(&item.name)) 127 | .spacing(8) 128 | .padding([2, 4]) 129 | .align_y(iced::Alignment::Center) 130 | .width(Length::Fill) 131 | .into(), 132 | }) 133 | .collect(); 134 | 135 | Column::from_vec(entries) 136 | .width(Length::Fill) 137 | .align_x(iced::Alignment::Center) 138 | .into() 139 | } 140 | 141 | fn breadcrumb<'a>(txt: &'a str, msg: Message) -> Element<'a, Message> { 142 | button(text(txt)) 143 | .on_press(msg) 144 | .style(button::text) 145 | .padding([0, 2]) 146 | .into() 147 | } 148 | 149 | pub fn view<'a>(state: &'a AppState, data: &'a SaveRequestState) -> Element<'a, Message> { 150 | let collection = data.col.and_then(|col| state.common.collections.get(col)); 151 | 152 | let name = Row::new() 153 | .push(text("Name")) 154 | .push(space::horizontal()) 155 | .push( 156 | text_input("Name", &data.name) 157 | .on_input(Message::NameChanged) 158 | .on_paste(Message::NameChanged), 159 | ) 160 | .align_y(iced::Alignment::Center) 161 | .spacing(4); 162 | 163 | let directory_path = collection 164 | .zip(data.folder_id) 165 | .map(|(c, f)| c.folder_path(f)) 166 | .unwrap_or_default(); 167 | 168 | let mut path = Row::new() 169 | .align_y(iced::Alignment::Center) 170 | .push(breadcrumb("Collection", Message::ClearSelection)) 171 | .push(collection.map(|_| text("/"))) 172 | .push(collection.map(|c| breadcrumb(&c.name, Message::ClearDirectory))); 173 | 174 | for folder in directory_path.iter() { 175 | path = path.push(text("/")); 176 | path = path.push(breadcrumb(&folder.name, Message::SelectDir(folder.id))); 177 | } 178 | 179 | let folder_selector = collection.map(|c| dir_selector(c, directory_path.last().copied())); 180 | 181 | let col_selector = scrollable(folder_selector.unwrap_or(col_selector(state))) 182 | .width(Length::Fill) 183 | .spacing(12) 184 | .height(Length::Fixed(300.0)); 185 | 186 | let col_selector = container(col_selector) 187 | .padding(4) 188 | .style(container::bordered_box); 189 | 190 | Column::new() 191 | .push(name) 192 | .push(container(text("Save to"))) 193 | .push( 194 | container(path.wrap()) 195 | .style(container::bordered_box) 196 | .padding(4), 197 | ) 198 | .push(col_selector) 199 | .width(400) 200 | .spacing(8) 201 | .into() 202 | } 203 | --------------------------------------------------------------------------------