Browse Source

Start implementing custom resizable widget

biblius 11 tháng trước cách đây
mục cha
commit
132a0e09f1
11 tập tin đã thay đổi với 1017 bổ sung và 508 xóa
  1. 29 75
      Cargo.lock
  2. 9 5
      Cargo.toml
  3. 2 2
      src/db.rs
  4. 13 0
      src/error.rs
  5. 11 6
      src/main.rs
  6. 180 318
      src/request.rs
  7. 59 0
      src/request/ctype.rs
  8. 239 0
      src/request/url.rs
  9. 382 58
      src/resizeable.rs
  10. 76 35
      src/state.rs
  11. 17 9
      src/state/pane.rs

+ 29 - 75
Cargo.lock

@@ -312,15 +312,6 @@ version = "1.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
 
-[[package]]
-name = "bincode"
-version = "1.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
-dependencies = [
- "serde",
-]
-
 [[package]]
 name = "bit-set"
 version = "0.8.0"
@@ -1670,13 +1661,12 @@ source = "git+https://github.com/iced-rs/iced.git?rev=26dfcb6d42a5cdd38ef0f75000
 dependencies = [
  "iced_core",
  "iced_debug",
- "iced_devtools",
  "iced_futures",
  "iced_renderer",
  "iced_runtime",
  "iced_widget",
  "iced_winit",
- "thiserror 2.0.16",
+ "thiserror 2.0.17",
 ]
 
 [[package]]
@@ -1694,21 +1684,6 @@ dependencies = [
  "web-time",
 ]
 
-[[package]]
-name = "iced_beacon"
-version = "0.14.0-dev"
-source = "git+https://github.com/iced-rs/iced.git?rev=26dfcb6d42a5cdd38ef0f75000484eaf4693f89a#26dfcb6d42a5cdd38ef0f75000484eaf4693f89a"
-dependencies = [
- "bincode",
- "futures",
- "iced_core",
- "log",
- "semver",
- "serde",
- "thiserror 2.0.16",
- "tokio",
-]
-
 [[package]]
 name = "iced_core"
 version = "0.14.0-dev"
@@ -1721,9 +1696,8 @@ dependencies = [
  "log",
  "num-traits",
  "rustc-hash 2.1.1",
- "serde",
  "smol_str",
- "thiserror 2.0.16",
+ "thiserror 2.0.17",
  "web-time",
 ]
 
@@ -1732,23 +1706,11 @@ name = "iced_debug"
 version = "0.14.0-dev"
 source = "git+https://github.com/iced-rs/iced.git?rev=26dfcb6d42a5cdd38ef0f75000484eaf4693f89a#26dfcb6d42a5cdd38ef0f75000484eaf4693f89a"
 dependencies = [
- "iced_beacon",
  "iced_core",
  "iced_futures",
  "log",
 ]
 
-[[package]]
-name = "iced_devtools"
-version = "0.14.0-dev"
-source = "git+https://github.com/iced-rs/iced.git?rev=26dfcb6d42a5cdd38ef0f75000484eaf4693f89a#26dfcb6d42a5cdd38ef0f75000484eaf4693f89a"
-dependencies = [
- "iced_debug",
- "iced_program",
- "iced_widget",
- "log",
-]
-
 [[package]]
 name = "iced_fonts"
 version = "0.3.0-dev"
@@ -1799,7 +1761,7 @@ dependencies = [
  "lyon_path",
  "raw-window-handle",
  "rustc-hash 2.1.1",
- "thiserror 2.0.16",
+ "thiserror 2.0.17",
  "unicode-segmentation",
 ]
 
@@ -1821,7 +1783,7 @@ dependencies = [
  "iced_tiny_skia",
  "iced_wgpu",
  "log",
- "thiserror 2.0.16",
+ "thiserror 2.0.17",
 ]
 
 [[package]]
@@ -1834,7 +1796,7 @@ dependencies = [
  "iced_debug",
  "iced_futures",
  "raw-window-handle",
- "thiserror 2.0.16",
+ "thiserror 2.0.17",
 ]
 
 [[package]]
@@ -1869,7 +1831,7 @@ dependencies = [
  "log",
  "lyon",
  "rustc-hash 2.1.1",
- "thiserror 2.0.16",
+ "thiserror 2.0.17",
  "wgpu",
 ]
 
@@ -1882,7 +1844,7 @@ dependencies = [
  "log",
  "num-traits",
  "rustc-hash 2.1.1",
- "thiserror 2.0.16",
+ "thiserror 2.0.17",
  "unicode-segmentation",
 ]
 
@@ -1896,7 +1858,7 @@ dependencies = [
  "log",
  "mundy",
  "rustc-hash 2.1.1",
- "thiserror 2.0.16",
+ "thiserror 2.0.17",
  "tracing",
  "wasm-bindgen-futures",
  "web-sys",
@@ -2440,7 +2402,7 @@ dependencies = [
  "once_cell",
  "rustc-hash 1.1.0",
  "spirv",
- "thiserror 2.0.16",
+ "thiserror 2.0.17",
  "unicode-ident",
 ]
 
@@ -3446,11 +3408,13 @@ version = "0.1.0"
 dependencies = [
  "iced",
  "iced_aw",
+ "mime",
  "nom",
  "reqwest",
  "serde",
  "serde_json",
  "sqlx",
+ "thiserror 2.0.17",
  "tokio",
  "tracing",
  "tracing-subscriber",
@@ -3674,21 +3638,11 @@ version = "1.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "16c2f82143577edb4921b71ede051dac62ca3c16084e918bf7b40c96ae10eb33"
 
-[[package]]
-name = "semver"
-version = "1.0.27"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
-dependencies = [
- "serde",
- "serde_core",
-]
-
 [[package]]
 name = "serde"
-version = "1.0.226"
+version = "1.0.228"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
 dependencies = [
  "serde_core",
  "serde_derive",
@@ -3696,18 +3650,18 @@ dependencies = [
 
 [[package]]
 name = "serde_core"
-version = "1.0.226"
+version = "1.0.228"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
 dependencies = [
  "serde_derive",
 ]
 
 [[package]]
 name = "serde_derive"
-version = "1.0.226"
+version = "1.0.228"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -4001,7 +3955,7 @@ dependencies = [
  "serde_json",
  "sha2",
  "smallvec",
- "thiserror 2.0.16",
+ "thiserror 2.0.17",
  "tokio",
  "tokio-stream",
  "tracing",
@@ -4083,7 +4037,7 @@ dependencies = [
  "smallvec",
  "sqlx-core",
  "stringprep",
- "thiserror 2.0.16",
+ "thiserror 2.0.17",
  "tracing",
  "whoami",
 ]
@@ -4120,7 +4074,7 @@ dependencies = [
  "smallvec",
  "sqlx-core",
  "stringprep",
- "thiserror 2.0.16",
+ "thiserror 2.0.17",
  "tracing",
  "whoami",
 ]
@@ -4144,7 +4098,7 @@ dependencies = [
  "serde",
  "serde_urlencoded",
  "sqlx-core",
- "thiserror 2.0.16",
+ "thiserror 2.0.17",
  "tracing",
  "url",
 ]
@@ -4295,11 +4249,11 @@ dependencies = [
 
 [[package]]
 name = "thiserror"
-version = "2.0.16"
+version = "2.0.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0"
+checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
 dependencies = [
- "thiserror-impl 2.0.16",
+ "thiserror-impl 2.0.17",
 ]
 
 [[package]]
@@ -4315,9 +4269,9 @@ dependencies = [
 
 [[package]]
 name = "thiserror-impl"
-version = "2.0.16"
+version = "2.0.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960"
+checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -5086,7 +5040,7 @@ dependencies = [
  "raw-window-handle",
  "rustc-hash 1.1.0",
  "smallvec",
- "thiserror 2.0.16",
+ "thiserror 2.0.17",
  "wgpu-core-deps-apple",
  "wgpu-core-deps-emscripten",
  "wgpu-core-deps-windows-linux-android",
@@ -5161,7 +5115,7 @@ dependencies = [
  "raw-window-handle",
  "renderdoc-sys",
  "smallvec",
- "thiserror 2.0.16",
+ "thiserror 2.0.17",
  "wasm-bindgen",
  "web-sys",
  "wgpu-types",
@@ -5179,7 +5133,7 @@ dependencies = [
  "bytemuck",
  "js-sys",
  "log",
- "thiserror 2.0.16",
+ "thiserror 2.0.17",
  "web-sys",
 ]
 

+ 9 - 5
Cargo.toml

@@ -6,16 +6,18 @@ authors = ["biblius"]
 edition = "2024"
 
 [dependencies]
-serde = { version = "1", features = ["derive"] }
-serde_json = "1"
+serde = { version = "1.0.228", features = ["derive"] }
+serde_json = "1.0.145"
 sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio"] }
-reqwest = { version = "0.12.15", features = ["multipart"] }
-tokio = { version = "1.44.1", features = ["macros"] }                # boa_engine = "0.19.0"
+reqwest = { version = "0.12.15", features = ["multipart", "json", "charset"] }
+tokio = { version = "1.44.1", features = ["macros"] }
+# boa_engine = "0.19.0"
 nom = "8.0.0"
 
 # Because 0.13.1 does not have widget::responsive even though
 # it is there in the docs???????
-iced = { version = "0.14.0-dev", features = ["tokio", "debug"] }
+# iced = { version = "0.14.0-dev", features = ["tokio", "debug"] }
+iced = { version = "0.14.0-dev", features = ["tokio", "advanced"] }
 iced_aw = { version = "0.13.0-dev", features = [
 	"menu",
 	"quad",
@@ -25,6 +27,8 @@ iced_aw = { version = "0.13.0-dev", features = [
 
 tracing = "0.1.41"
 tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
+thiserror = "2.0.17"
+mime = "0.3.17"
 
 [patch.crates-io]
 iced = { git = "https://github.com/iced-rs/iced.git", rev = "26dfcb6d42a5cdd38ef0f75000484eaf4693f89a" }

+ 2 - 2
src/db.rs

@@ -1,11 +1,11 @@
 use crate::{
     data::{TemplateEnvironmentVariable, WorkspaceEnvironment},
     model::{WorkspaceCollection, WorkspaceEntryItem},
-    request::{ContentType, WorkspaceRequest},
+    request::{WorkspaceRequest, ctype::ContentType},
 };
 use sqlx::Type;
 use sqlx::sqlite::SqlitePool;
-use std::collections::{BTreeMap, HashMap};
+use std::collections::HashMap;
 
 #[derive(Debug, Clone)]
 pub struct Workspace {

+ 13 - 0
src/error.rs

@@ -0,0 +1,13 @@
+#[derive(Debug, thiserror::Error)]
+pub enum AppError {
+    #[error("{0}")]
+    Sqlx(#[from] sqlx::Error),
+    #[error("{0}")]
+    Reqwest(#[from] reqwest::Error),
+    #[error("{0}")]
+    HeaderToStr(#[from] reqwest::header::ToStrError),
+    #[error("{0}")]
+    MimeFromStr(#[from] mime::FromStrError),
+    #[error("{0}")]
+    SerdeJson(#[from] serde_json::Error),
+}

+ 11 - 6
src/main.rs

@@ -1,5 +1,6 @@
 mod data;
 mod db;
+mod error;
 mod menu;
 mod model;
 mod request;
@@ -9,22 +10,22 @@ mod state;
 use std::collections::HashMap;
 use std::i64;
 
-use reqwest::Body;
-use reqwest::header::{self, HeaderMap, HeaderValue};
-
 use crate::data::WorkspaceEnvironment;
 use crate::db::Workspace;
+use crate::error::AppError;
 use crate::menu::{EntryMenuMessage, WorkspaceMenuMessage};
 use crate::model::WorkspaceEntryItem;
-use crate::request::{ContentType, HttpRequestParameters, RequestMessage};
-use crate::resizeable::{Resizable, ResizeableMessage};
+use crate::request::{RequestMessage, ResponseMessage};
+use crate::resizeable::{ResizableX, ResizeableMessage};
 use crate::state::AppState;
 use crate::state::pane::PaneMessage;
 
+pub type AppResult<T> = Result<T, AppError>;
+
 fn main() -> iced::Result {
     tracing_subscriber::fmt::init();
     iced::application(AppState::default, AppState::update, AppState::view)
-        .subscription(|_| Resizable::subscribe())
+        .subscription(|_| resizeable::subscribe())
         .run()
 }
 
@@ -49,5 +50,9 @@ pub enum Message {
 
     Resizable(ResizeableMessage),
 
+    /// Messages from working on requests in the main view.
     Request(RequestMessage),
+
+    /// Message triggered when a response is received from a sent request.
+    Response(ResponseMessage),
 }

+ 180 - 318
src/request.rs

@@ -1,26 +1,25 @@
-use std::{collections::HashMap, str::FromStr};
+pub mod ctype;
+pub mod url;
+
+use iced::widget::scrollable;
 
 use iced::{
     Element,
-    Length::FillPortion,
-    widget::{button, column, container, row, rule, text, text_input},
-};
-use nom::{
-    Parser,
-    bytes::complete::{tag, take_until, take_until1, take_while, take_while1},
-    character::complete::char,
-    multi::many0,
-    sequence::{preceded, separated_pair},
+    Length::{Fill, FillPortion},
+    widget::{
+        button, column, container, responsive, row, rule, space::horizontal, text, text_input,
+    },
 };
 use reqwest::{
-    Body, Method,
+    Body, Method, StatusCode,
     header::{self, HeaderMap, HeaderValue},
 };
-use sqlx::error::BoxDynError;
+use std::{collections::HashMap, str::FromStr};
 
 use crate::{
-    Message,
+    AppResult, Message,
     db::{RequestHeader, RequestParams, WorkspaceEntry},
+    request::ctype::ContentType,
 };
 
 pub const DEFAULT_HEADERS: &'static [(&'static str, &'static str)] = &[
@@ -29,10 +28,7 @@ pub const DEFAULT_HEADERS: &'static [(&'static str, &'static str)] = &[
     ("accept-encoding", "gzip, defalte, br"),
 ];
 
-pub async fn send(
-    client: reqwest::Client,
-    req: HttpRequestParameters,
-) -> Result<Option<String>, String> {
+pub async fn send(client: reqwest::Client, req: HttpRequestParameters) -> AppResult<HttpResponse> {
     let HttpRequestParameters {
         url,
         method,
@@ -55,11 +51,7 @@ pub async fn send(
                 // Handled by reqwest
                 ContentType::FormData | ContentType::FormUrlEncoded => {}
             };
-            let body = match Body::try_from(body.content) {
-                Ok(b) => b,
-                Err(e) => return Err(e.to_string()),
-            };
-            Some(body)
+            Some(Body::from(body.content))
         }
         None => None,
     };
@@ -70,13 +62,26 @@ pub async fn send(
         req = req.body(body)
     }
 
-    let res = req.send().await;
-
-    let content = res.unwrap().text().await.unwrap();
-
-    tracing::debug!("content: {content}");
+    let res = match req.send().await {
+        Ok(res) => {
+            tracing::debug!(
+                "{} {} {:?} {:#?}",
+                res.remote_addr()
+                    .map(|addr| addr.to_string())
+                    .unwrap_or(String::new()),
+                res.status(),
+                res.content_length(),
+                res.headers()
+            );
+            let status = res.status();
+            let headers = res.headers().clone();
+            let body = ResponseBody::try_from_response(res).await?;
+            HttpResponse::new(status, headers, body)
+        }
+        Err(e) => return Err(e.into()),
+    };
 
-    Ok(None)
+    Ok(res)
 }
 
 #[derive(Debug, Clone)]
@@ -116,7 +121,7 @@ impl WorkspaceRequest {
         }
     }
 
-    pub fn view<'a>(&'a self) -> Element<'a, Message> {
+    pub fn view_req<'a>(&'a self) -> Element<'a, Message> {
         let url_input = row![
             button(text(&self.method)).width(FillPortion(1)),
             text_input("", &self.url)
@@ -172,9 +177,78 @@ impl WorkspaceRequest {
             body_section = body_section.push(text("TODO: REQUEST BODY"));
         }
 
-        let content = column![url_input, param_section, header_section, body_section];
+        column![url_input, param_section, header_section, body_section].into()
+    }
+
+    pub fn view_res<'a>(
+        &'a self,
+        last_response: Option<&'a HttpResponse>,
+        running: bool,
+    ) -> Element<'a, Message> {
+        if running {
+            container("Running").width(Fill).center(Fill).into()
+        } else if let Some(res) = last_response {
+            let icon = if res.status.is_success() { "o" } else { "x" };
+            let ctype = if let Some(ct) = res.headers.get(header::CONTENT_TYPE) {
+                match ct.to_str() {
+                    Ok(ct) => ct,
+                    Err(e) => {
+                        tracing::warn!("unable to parse content-type header: {e}");
+                        "unknown"
+                    }
+                }
+            } else {
+                "unknown"
+            };
 
-        container(content).into()
+            let header = row![
+                text(res.status.as_u16()).width(Fill).center(),
+                text(res.status.canonical_reason().unwrap_or(""))
+                    .width(Fill)
+                    .center(),
+                text(ctype).width(FillPortion(10)).center(),
+                horizontal().width(Fill),
+                text(icon).width(Fill).center(),
+            ]
+            .height(FillPortion(1));
+
+            let body = match &res.body {
+                Some(b) => match b {
+                    ResponseBody::Text(value) | ResponseBody::Json(value) => {
+                        container(text(value.as_str()).wrapping(text::Wrapping::Word))
+                    }
+                },
+                None => container(""),
+            }
+            .padding(10);
+
+            let body = scrollable(body)
+                .height(FillPortion(15))
+                // .direction(Direction::Both {
+                //     vertical: Scrollbar::default(),
+                //     horizontal: Scrollbar::default(),
+                // });
+            ;
+
+            column![header, body].into()
+        } else {
+            container(text("No response").center())
+                .width(Fill)
+                .height(Fill)
+                .center(Fill)
+                .style(|_| iced::widget::container::Style {
+                    text_color: Some(iced::Color::from_rgba(1., 1., 1., 0.7)),
+                    background: None,
+                    border: iced::Border {
+                        color: iced::Color::WHITE,
+                        width: 1.,
+                        radius: 0.0.into(),
+                    },
+                    shadow: iced::Shadow::default(),
+                    snap: true,
+                })
+                .into()
+        }
     }
 
     pub fn from_params_and_headers(
@@ -209,70 +283,6 @@ pub struct HttpRequestParameters {
     pub body: Option<RequestBody>,
 }
 
-#[derive(Debug, Clone)]
-pub struct RequestBody {
-    pub content: String,
-    pub ty: ContentType,
-}
-
-#[derive(Debug, Clone)]
-pub enum ContentType {
-    Text,
-    Json,
-    Xml,
-    FormData,
-    FormUrlEncoded,
-    // TODO: files
-    // Binary(reqwest::Body::)
-}
-
-impl ContentType {
-    pub fn as_str(&self) -> &'static str {
-        match self {
-            ContentType::Text => "text",
-            ContentType::Json => "json",
-            ContentType::Xml => "xml",
-            ContentType::FormData => "form_data",
-            ContentType::FormUrlEncoded => "form_urlencoded",
-        }
-    }
-
-    pub fn from_str(s: &str) -> Option<Self> {
-        match s {
-            "text" => Some(ContentType::Text),
-            "json" => Some(ContentType::Json),
-            "xml" => Some(ContentType::Xml),
-            "form_data" => Some(ContentType::FormData),
-            "form_urlencoded" => Some(ContentType::FormUrlEncoded),
-            _ => None,
-        }
-    }
-}
-
-// ---- SQLx integration ----
-
-impl sqlx::Type<sqlx::Sqlite> for ContentType {
-    fn type_info() -> sqlx::sqlite::SqliteTypeInfo {
-        <&str as sqlx::Type<sqlx::Sqlite>>::type_info()
-    }
-}
-
-impl<'q> sqlx::Encode<'q, sqlx::Sqlite> for ContentType {
-    fn encode_by_ref(
-        &self,
-        buf: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
-    ) -> Result<sqlx::encode::IsNull, BoxDynError> {
-        <&str as sqlx::Encode<sqlx::Sqlite>>::encode(self.as_str(), buf)
-    }
-}
-
-impl<'r> sqlx::Decode<'r, sqlx::Sqlite> for ContentType {
-    fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
-        let s = <&str as sqlx::Decode<sqlx::Sqlite>>::decode(value)?;
-        ContentType::from_str(s).ok_or_else(|| format!("invalid content type: {}", s).into())
-    }
-}
-
 impl TryFrom<WorkspaceRequest> for HttpRequestParameters {
     type Error = String;
 
@@ -297,247 +307,99 @@ impl TryFrom<WorkspaceRequest> for HttpRequestParameters {
 }
 
 #[derive(Debug, Clone)]
-pub enum RequestMessage {
-    UrlUpdated(String),
-    Run(i64),
-    SectionUpdate(RequestSectionUpdate),
-}
-
-#[derive(Debug, Clone, Copy)]
-pub enum RequestSectionUpdate {
-    Params,
-    Headers,
-    Body,
-}
-
-/// A fully deconstructed URL from a workspace request.
-/// Used as an intermediate step for populating the final URL with variables.
-#[derive(Debug)]
-pub struct RequestUrl<'a> {
-    /// The URL scheme, e.g. `http`.
-    pub scheme: &'a str,
-
-    /// The URL host, includes the port if specified.
-    pub host: &'a str,
-
-    /// The URL path segments.
-    ///
-    /// All segments will be formatted as `/segment`, meaning empty Static
-    /// fields represent a `/`, which is usually trailing.
-    pub path: Vec<Segment<'a>>,
-
-    /// Query parameters.
-    pub query_params: Vec<(&'a str, &'a str)>,
+pub struct HttpResponse {
+    pub status: StatusCode,
+    pub headers: HeaderMap,
+    pub body: Option<ResponseBody>,
 }
 
-impl<'a> RequestUrl<'a> {
-    pub fn parse(input: &'a str) -> Result<Self, nom::Err<nom::error::Error<&'a str>>> {
-        let (input, scheme) = take_while1(char::is_alphabetic)(input)?;
-
-        let (input, _) = tag("://")(input)?;
-
-        let mut path_parser = many0(preceded(
-            char('/'),
-            take_while(|c: char| c.is_ascii_alphanumeric() || c == ':'),
-        ));
-
-        let result = take_until1::<_, _, nom::error::Error<_>>("?")(input);
-        match result {
-            // URL has query parameters
-            Ok((query, path)) => {
-                // Parse query
-                // First char will always be a '?' since we parsed succesfully
-                let mut query = &query[1..];
-                let mut query_params = vec![];
-
-                loop {
-                    if query.is_empty() {
-                        break;
-                    }
-
-                    let (i, params) = separated_pair(
-                        take_while(|c: char| c != '='),
-                        char('='),
-                        take_while(|c: char| c != '&'),
-                    )
-                    .parse(query)?;
-
-                    query = i;
-                    query_params.push((params.0, params.1));
-
-                    if let Ok((i, _)) = char::<_, nom::error::Error<_>>('&').parse(query) {
-                        query = i;
-                    }
-                }
-
-                debug_assert!(query.is_empty());
-
-                // Check path segments
-
-                match take_until::<_, _, nom::error::Error<_>>("/")(path) {
-                    // Path exists
-                    Ok((path, host)) => {
-                        let (input, segments) = path_parser.parse(path)?;
-                        debug_assert!(input.is_empty());
-                        Ok(RequestUrl {
-                            scheme,
-                            host,
-                            path: segments
-                                .into_iter()
-                                .map(|segment| {
-                                    segment
-                                        .strip_prefix(':')
-                                        .map_or(Segment::Static(segment), Segment::Dynamic)
-                                })
-                                .collect(),
-                            query_params,
-                        })
-                    }
-
-                    // No path segments
-                    Err(_) => Ok(RequestUrl {
-                        scheme,
-                        host: path,
-                        path: vec![],
-                        query_params,
-                    }),
-                }
-            }
-            // No query params
-            Err(_) => {
-                match take_until::<_, _, nom::error::Error<_>>("/")(input) {
-                    // Path exists
-                    Ok((path, host)) => {
-                        let (input, segments) = path_parser.parse(path)?;
-                        debug_assert!(input.is_empty());
-                        Ok(RequestUrl {
-                            scheme,
-                            host,
-                            path: segments
-                                .into_iter()
-                                .map(|segment| {
-                                    segment
-                                        .strip_prefix(':')
-                                        .map_or(Segment::Static(segment), Segment::Dynamic)
-                                })
-                                .collect(),
-                            query_params: vec![],
-                        })
-                    }
-                    // No path segments
-                    Err(_) => Ok(RequestUrl {
-                        scheme,
-                        host: input,
-                        path: vec![],
-                        query_params: vec![],
-                    }),
-                }
-            }
+impl HttpResponse {
+    pub fn new(status: StatusCode, headers: HeaderMap, body: Option<ResponseBody>) -> Self {
+        Self {
+            status,
+            headers,
+            body,
         }
     }
 }
 
-#[derive(Debug, PartialEq, Eq)]
-pub enum Segment<'a> {
-    /// Path segments that do not change.
-    /// The value is the final path value.
-    Static(&'a str),
-
-    /// Path segments that depend on request configuration.
-    /// The value is the name of the variable in the request configuration
-    /// that contains the final path value.
-    Dynamic(&'a str),
+#[derive(Debug, Clone)]
+pub enum ResponseBody {
+    Text(String),
+
+    /// A pretty printed JSON string
+    Json(String),
+    // TODO:
+    // Xml(String),
+    // HTML
+    // Binary
 }
 
-#[cfg(test)]
-mod tests {
-    use super::{RequestUrl, Segment};
-
-    #[test]
-    fn parses_path_placeholders() {
-        let input = "http://localhost:4000/foo/:bar/bax";
-
-        let expected_path = vec![
-            Segment::Static("foo"),
-            Segment::Dynamic("bar"),
-            Segment::Static("bax"),
-        ];
-
-        let url = RequestUrl::parse(input).unwrap();
-
-        assert_eq!("http", url.scheme);
-        assert_eq!("localhost:4000", url.host);
-        assert_eq!(expected_path, url.path);
-        assert!(url.query_params.is_empty());
-    }
-
-    #[test]
-    fn parses_path_placeholders_trailing_slash() {
-        let input = "http://localhost:4000/foo/:bar/bax/";
-
-        let expected_path = vec![
-            Segment::Static("foo"),
-            Segment::Dynamic("bar"),
-            Segment::Static("bax"),
-            Segment::Static(""),
-        ];
-
-        let url = RequestUrl::parse(input).unwrap();
+impl ResponseBody {
+    pub async fn try_from_response(res: reqwest::Response) -> AppResult<Option<Self>> {
+        if res.content_length().is_none() {
+            tracing::debug!("Response no content");
+        }
 
-        assert_eq!("http", url.scheme);
-        assert_eq!("localhost:4000", url.host);
-        assert_eq!(expected_path, url.path);
-        assert!(url.query_params.is_empty());
-    }
+        let Some(ct) = res.headers().get(header::CONTENT_TYPE) else {
+            tracing::warn!(
+                "Response does not contain content-type header, attempting to read as text"
+            );
+            return Ok(Some(Self::Text(res.text().await?)));
+        };
 
-    #[test]
-    fn parses_no_path_segments() {
-        let input = "http://localhost:4000";
+        let ct = match ct.to_str() {
+            Ok(ct) => ct,
+            Err(e) => {
+                tracing::warn!("Unable to parse content-type header: {e}");
+                return Err(e.into());
+            }
+        };
 
-        let url = RequestUrl::parse(input).unwrap();
+        let ct: mime::Mime = ct.parse()?;
 
-        assert_eq!("http", url.scheme);
-        assert_eq!("localhost:4000", url.host);
-        assert!(url.path.is_empty());
-        assert!(url.query_params.is_empty());
-    }
+        if ct.subtype() == mime::JSON || ct.suffix().is_some_and(|s| s == mime::JSON) {
+            tracing::debug!("reading body");
+            let json = serde_json::to_string_pretty(&res.json::<serde_json::Value>().await?)?;
+            tracing::debug!("body read");
+            return Ok(Some(Self::Json(json)));
+        }
 
-    #[test]
-    fn parse_no_path_segments_trailing_slash() {
-        let input = "http://localhost:4000/";
+        if ct.type_() == mime::TEXT {
+            tracing::debug!("reading body");
+            let text = res.text().await?;
+            tracing::debug!("body read");
+            return Ok(Some(Self::Text(text)));
+        }
 
-        let url = RequestUrl::parse(input).unwrap();
+        tracing::warn!("Body did not match anything!");
 
-        assert_eq!("http", url.scheme);
-        assert_eq!("localhost:4000", url.host);
-        assert_eq!(vec![Segment::Static("")], url.path);
-        assert!(url.query_params.is_empty());
+        Ok(None)
     }
+}
 
-    #[test]
-    fn parse_query_params_no_path() {
-        let input = "http://localhost:4000?foo=bar&baz=bax";
-
-        let url = RequestUrl::parse(input).unwrap();
-
-        assert_eq!("http", url.scheme);
-        assert_eq!("localhost:4000", url.host);
-        assert!(url.path.is_empty());
-        assert_eq!(vec![("foo", "bar"), ("baz", "bax")], url.query_params);
-    }
+#[derive(Debug, Clone)]
+pub struct RequestBody {
+    pub content: String,
+    pub ty: ContentType,
+}
 
-    #[test]
-    fn parse_query_params_with_path() {
-        let input = "http://localhost:4000/foo/:bar?foo=bar&baz=bax";
+#[derive(Debug, Clone)]
+pub enum RequestMessage {
+    UrlUpdated(String),
+    Run(i64),
+    SectionUpdate(RequestSectionUpdate),
+}
 
-        let url = RequestUrl::parse(input).unwrap();
+#[derive(Debug, Clone, Copy)]
+pub enum RequestSectionUpdate {
+    Params,
+    Headers,
+    Body,
+}
 
-        assert_eq!("http", url.scheme);
-        assert_eq!("localhost:4000", url.host);
-        assert_eq!(
-            vec![Segment::Static("foo"), Segment::Dynamic("bar")],
-            url.path
-        );
-        assert_eq!(vec![("foo", "bar"), ("baz", "bax")], url.query_params);
-    }
+#[derive(Debug, Clone)]
+pub enum ResponseMessage {
+    Success(i64, HttpResponse),
+    Error(i64, String),
 }

+ 59 - 0
src/request/ctype.rs

@@ -0,0 +1,59 @@
+use sqlx::error::BoxDynError;
+
+#[derive(Debug, Clone)]
+pub enum ContentType {
+    Text,
+    Json,
+    Xml,
+    FormData,
+    FormUrlEncoded,
+    // TODO: files
+    // Binary(reqwest::Body::)
+}
+
+impl ContentType {
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            ContentType::Text => "text",
+            ContentType::Json => "json",
+            ContentType::Xml => "xml",
+            ContentType::FormData => "form_data",
+            ContentType::FormUrlEncoded => "form_urlencoded",
+        }
+    }
+
+    pub fn from_str(s: &str) -> Option<Self> {
+        match s {
+            "text" => Some(ContentType::Text),
+            "json" => Some(ContentType::Json),
+            "xml" => Some(ContentType::Xml),
+            "form_data" => Some(ContentType::FormData),
+            "form_urlencoded" => Some(ContentType::FormUrlEncoded),
+            _ => None,
+        }
+    }
+}
+
+// SQLx
+
+impl sqlx::Type<sqlx::Sqlite> for ContentType {
+    fn type_info() -> sqlx::sqlite::SqliteTypeInfo {
+        <&str as sqlx::Type<sqlx::Sqlite>>::type_info()
+    }
+}
+
+impl<'q> sqlx::Encode<'q, sqlx::Sqlite> for ContentType {
+    fn encode_by_ref(
+        &self,
+        buf: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
+    ) -> Result<sqlx::encode::IsNull, BoxDynError> {
+        <&str as sqlx::Encode<sqlx::Sqlite>>::encode(self.as_str(), buf)
+    }
+}
+
+impl<'r> sqlx::Decode<'r, sqlx::Sqlite> for ContentType {
+    fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
+        let s = <&str as sqlx::Decode<sqlx::Sqlite>>::decode(value)?;
+        ContentType::from_str(s).ok_or_else(|| format!("invalid content type: {}", s).into())
+    }
+}

+ 239 - 0
src/request/url.rs

@@ -0,0 +1,239 @@
+use nom::{
+    Parser,
+    bytes::complete::{tag, take_until, take_until1, take_while, take_while1},
+    character::complete::char,
+    multi::many0,
+    sequence::{preceded, separated_pair},
+};
+
+/// A fully deconstructed URL from a workspace request.
+/// Used as an intermediate step for populating the final URL with variables.
+#[derive(Debug)]
+pub struct RequestUrl<'a> {
+    /// The URL scheme, e.g. `http`.
+    pub scheme: &'a str,
+
+    /// The URL host, includes the port if specified.
+    pub host: &'a str,
+
+    /// The URL path segments.
+    ///
+    /// All segments will be formatted as `/segment`, meaning empty Static
+    /// fields represent a `/`, which is usually trailing.
+    pub path: Vec<Segment<'a>>,
+
+    /// Query parameters.
+    pub query_params: Vec<(&'a str, &'a str)>,
+}
+
+impl<'a> RequestUrl<'a> {
+    pub fn parse(input: &'a str) -> Result<Self, nom::Err<nom::error::Error<&'a str>>> {
+        let (input, scheme) = take_while1(char::is_alphabetic)(input)?;
+
+        let (input, _) = tag("://")(input)?;
+
+        let mut path_parser = many0(preceded(
+            char('/'),
+            take_while(|c: char| c.is_ascii_alphanumeric() || c == ':'),
+        ));
+
+        let result = take_until1::<_, _, nom::error::Error<_>>("?")(input);
+        match result {
+            // URL has query parameters
+            Ok((query, path)) => {
+                // Parse query
+                // First char will always be a '?' since we parsed succesfully
+                let mut query = &query[1..];
+                let mut query_params = vec![];
+
+                loop {
+                    if query.is_empty() {
+                        break;
+                    }
+
+                    let (i, params) = separated_pair(
+                        take_while(|c: char| c != '='),
+                        char('='),
+                        take_while(|c: char| c != '&'),
+                    )
+                    .parse(query)?;
+
+                    query = i;
+                    query_params.push((params.0, params.1));
+
+                    if let Ok((i, _)) = char::<_, nom::error::Error<_>>('&').parse(query) {
+                        query = i;
+                    }
+                }
+
+                debug_assert!(query.is_empty());
+
+                // Check path segments
+
+                match take_until::<_, _, nom::error::Error<_>>("/")(path) {
+                    // Path exists
+                    Ok((path, host)) => {
+                        let (input, segments) = path_parser.parse(path)?;
+                        debug_assert!(input.is_empty());
+                        Ok(RequestUrl {
+                            scheme,
+                            host,
+                            path: segments
+                                .into_iter()
+                                .map(|segment| {
+                                    segment
+                                        .strip_prefix(':')
+                                        .map_or(Segment::Static(segment), Segment::Dynamic)
+                                })
+                                .collect(),
+                            query_params,
+                        })
+                    }
+
+                    // No path segments
+                    Err(_) => Ok(RequestUrl {
+                        scheme,
+                        host: path,
+                        path: vec![],
+                        query_params,
+                    }),
+                }
+            }
+            // No query params
+            Err(_) => {
+                match take_until::<_, _, nom::error::Error<_>>("/")(input) {
+                    // Path exists
+                    Ok((path, host)) => {
+                        let (input, segments) = path_parser.parse(path)?;
+                        debug_assert!(input.is_empty());
+                        Ok(RequestUrl {
+                            scheme,
+                            host,
+                            path: segments
+                                .into_iter()
+                                .map(|segment| {
+                                    segment
+                                        .strip_prefix(':')
+                                        .map_or(Segment::Static(segment), Segment::Dynamic)
+                                })
+                                .collect(),
+                            query_params: vec![],
+                        })
+                    }
+                    // No path segments
+                    Err(_) => Ok(RequestUrl {
+                        scheme,
+                        host: input,
+                        path: vec![],
+                        query_params: vec![],
+                    }),
+                }
+            }
+        }
+    }
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum Segment<'a> {
+    /// Path segments that do not change.
+    /// The value is the final path value.
+    Static(&'a str),
+
+    /// Path segments that depend on request configuration.
+    /// The value is the name of the variable in the request configuration
+    /// that contains the final path value.
+    Dynamic(&'a str),
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{RequestUrl, Segment};
+
+    #[test]
+    fn parses_path_placeholders() {
+        let input = "http://localhost:4000/foo/:bar/bax";
+
+        let expected_path = vec![
+            Segment::Static("foo"),
+            Segment::Dynamic("bar"),
+            Segment::Static("bax"),
+        ];
+
+        let url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http", url.scheme);
+        assert_eq!("localhost:4000", url.host);
+        assert_eq!(expected_path, url.path);
+        assert!(url.query_params.is_empty());
+    }
+
+    #[test]
+    fn parses_path_placeholders_trailing_slash() {
+        let input = "http://localhost:4000/foo/:bar/bax/";
+
+        let expected_path = vec![
+            Segment::Static("foo"),
+            Segment::Dynamic("bar"),
+            Segment::Static("bax"),
+            Segment::Static(""),
+        ];
+
+        let url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http", url.scheme);
+        assert_eq!("localhost:4000", url.host);
+        assert_eq!(expected_path, url.path);
+        assert!(url.query_params.is_empty());
+    }
+
+    #[test]
+    fn parses_no_path_segments() {
+        let input = "http://localhost:4000";
+
+        let url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http", url.scheme);
+        assert_eq!("localhost:4000", url.host);
+        assert!(url.path.is_empty());
+        assert!(url.query_params.is_empty());
+    }
+
+    #[test]
+    fn parse_no_path_segments_trailing_slash() {
+        let input = "http://localhost:4000/";
+
+        let url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http", url.scheme);
+        assert_eq!("localhost:4000", url.host);
+        assert_eq!(vec![Segment::Static("")], url.path);
+        assert!(url.query_params.is_empty());
+    }
+
+    #[test]
+    fn parse_query_params_no_path() {
+        let input = "http://localhost:4000?foo=bar&baz=bax";
+
+        let url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http", url.scheme);
+        assert_eq!("localhost:4000", url.host);
+        assert!(url.path.is_empty());
+        assert_eq!(vec![("foo", "bar"), ("baz", "bax")], url.query_params);
+    }
+
+    #[test]
+    fn parse_query_params_with_path() {
+        let input = "http://localhost:4000/foo/:bar?foo=bar&baz=bax";
+
+        let url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http", url.scheme);
+        assert_eq!("localhost:4000", url.host);
+        assert_eq!(
+            vec![Segment::Static("foo"), Segment::Dynamic("bar")],
+            url.path
+        );
+        assert_eq!(vec![("foo", "bar"), ("baz", "bax")], url.query_params);
+    }
+}

+ 382 - 58
src/resizeable.rs

@@ -1,14 +1,33 @@
 use std::f32::consts::PI;
 
 use iced::{
-    Element, Event, Length, Subscription, mouse,
+    Alignment, Color, Element, Event,
+    Length::{self, Fill},
+    Point, Rectangle, Size, Subscription,
+    advanced::{
+        Layout, Widget,
+        layout::{Limits, Node},
+        renderer,
+        widget::{Tree, tree},
+    },
+    border,
+    mouse::{self, Interaction},
     widget::{column, container, row, text},
 };
 
 use crate::Message;
 
-pub struct Resizable {
-    panel_width: f32,
+pub fn subscribe() -> Subscription<Message> {
+    // Subscribe to mouse events so we can inspect mouse movement and buttons for the resizable.
+    iced::event::listen_with(|e, _status, _window_id| match e {
+        Event::Mouse(e) => Some(Message::Resizable(ResizeableMessage::EventOccurred(e))),
+        _ => None,
+    })
+}
+
+#[derive(Debug)]
+pub struct ResizableX {
+    width: f32,
     hover_border: bool,
     dragging: bool,
 
@@ -20,10 +39,10 @@ pub struct Resizable {
     last_mouse_x: f32,
 }
 
-impl Resizable {
+impl ResizableX {
     pub fn new(initial_width: f32, min_width: f32, max_width: f32) -> Self {
         Self {
-            panel_width: initial_width,
+            width: initial_width,
             hover_border: false,
             dragging: false,
             min_width,
@@ -32,55 +51,48 @@ impl Resizable {
         }
     }
 
-    pub fn subscribe() -> Subscription<Message> {
-        // Subscribe to all native events so we can inspect mouse movement and buttons.
-        iced::event::listen().map(|e| Message::Resizable(ResizeableMessage::EventOccurred(e)))
-    }
-
     pub fn update(&mut self, message: ResizeableMessage) {
         match message {
             ResizeableMessage::EventOccurred(event) => {
                 // We're only interested in mouse events here
-                if let Event::Mouse(mouse_event) = event {
-                    match mouse_event {
-                        mouse::Event::CursorMoved { position } => {
-                            let x = position.x;
+                match event {
+                    mouse::Event::CursorMoved { position } => {
+                        let x = position.x;
+                        self.last_mouse_x = x;
+                        // hover region: within 6 px of the current right border
+                        let near_border = (x - self.width).abs() <= 6.0;
+
+                        // if already dragging, update width according to cursor x
+                        if self.dragging {
+                            // clamp width between min and max
+                            let new_w = x.clamp(self.min_width as f32, self.max_width as f32);
+                            self.width = new_w.round();
                             self.last_mouse_x = x;
-                            // hover region: within 6 px of the current right border
-                            let near_border = (x - self.panel_width).abs() <= 6.0;
-
-                            // if already dragging, update width according to cursor x
-                            if self.dragging {
-                                // clamp width between min and max
-                                let new_w = x.clamp(self.min_width as f32, self.max_width as f32);
-                                self.panel_width = new_w.round();
-                                self.last_mouse_x = x;
-                                // keep hover true while dragging to show consistent cursor feedback
-                                self.hover_border = true;
-                            } else {
-                                // only update hover when not dragging
-                                self.hover_border = near_border;
-                            }
+                            // keep hover true while dragging to show consistent cursor feedback
+                            self.hover_border = true;
+                        } else {
+                            // only update hover when not dragging
+                            self.hover_border = near_border;
                         }
-                        mouse::Event::ButtonPressed(mouse::Button::Left) => {
-                            // Start dragging if the left mouse button pressed while cursor near border
-                            // Note: We rely on last cursor position reported in CursorMoved events.
-                            // If there was no previous CursorMoved, last_mouse_x might be 0; that's fine.
-                            let x = self.last_mouse_x;
-                            if (x - (self.panel_width as f32)).abs() <= 6.0 {
-                                self.dragging = true;
-                            } else {
-                                // If clicking inside the panel area but not on border, nothing special
-                            }
+                    }
+                    mouse::Event::ButtonPressed(mouse::Button::Left) => {
+                        // Start dragging if the left mouse button pressed while cursor near border
+                        // Note: We rely on last cursor position reported in CursorMoved events.
+                        // If there was no previous CursorMoved, last_mouse_x might be 0; that's fine.
+                        let x = self.last_mouse_x;
+                        if (x - (self.width as f32)).abs() <= 6.0 {
+                            self.dragging = true;
+                        } else {
+                            // If clicking inside the panel area but not on border, nothing special
                         }
-                        mouse::Event::ButtonReleased(mouse::Button::Left) => {
-                            // stop dragging
-                            if self.dragging {
-                                self.dragging = false;
-                            }
+                    }
+                    mouse::Event::ButtonReleased(mouse::Button::Left) => {
+                        // stop dragging
+                        if self.dragging {
+                            self.dragging = false;
                         }
-                        _ => {}
                     }
+                    _ => {}
                 }
             }
             ResizeableMessage::UpdateHover(b) => {
@@ -95,19 +107,10 @@ impl Resizable {
         R: Into<Element<'a, M>>,
         M: 'a,
     {
-        let handle_width = 6_f32;
-
         // handle visuals: when hovering or dragging, show thicker line
         let handle: Element<'a, M> = {
-            let lines = if self.dragging {
-                column![text(" ").height(Length::Fixed(80.))]
-            } else if self.hover_border {
-                column![text(" ").height(Length::Fixed(80.))]
-            } else {
-                column![text(" ").height(Length::Fixed(80.))]
-            };
-            container(lines)
-                .width(Length::Fixed(handle_width))
+            container(" ")
+                .width(Length::Fixed(6.))
                 .height(Length::Fill)
                 .style(|_| iced::widget::container::Style {
                     text_color: Some(iced::Color::from_rgba(1., 1., 1., 0.7)),
@@ -127,7 +130,7 @@ impl Resizable {
         let right: Element<'a, M> = right.into();
 
         let left: Element<'a, M> = row![left, handle]
-            .width(Length::Fixed(self.panel_width))
+            .width(Length::Fixed(self.width))
             .height(Length::Fill)
             .spacing(0)
             .into();
@@ -141,10 +144,331 @@ impl Resizable {
     }
 }
 
+// #[derive(Debug)]
+pub struct ResizableY<'a, Message, Theme, Renderer> {
+    height: f32,
+    hover_border: bool,
+    dragging: bool,
+
+    up: Element<'a, Message, Theme, Renderer>,
+    down: Element<'a, Message, Theme, Renderer>,
+
+    /// constraints
+    min_height: f32,
+    max_height: f32,
+
+    /// last known mouse position (used while dragging)
+    last_mouse_y: f32,
+}
+
+struct ResizableState;
+
+impl<'a, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
+    for ResizableY<'a, Message, Theme, Renderer>
+where
+    Renderer: iced::advanced::Renderer,
+{
+    fn size(&self) -> iced::Size<Length> {
+        iced::Size::new(Fill, Fill)
+    }
+
+    fn size_hint(&self) -> Size<Length> {
+        self.size()
+    }
+
+    fn layout(
+        &mut self,
+        tree: &mut iced::advanced::widget::Tree,
+        renderer: &Renderer,
+        limits: &iced::advanced::layout::Limits,
+    ) -> iced::advanced::layout::Node {
+        let size = limits.max();
+
+        let up_node = self
+            .up
+            .as_widget_mut()
+            .layout(&mut tree.children[0], renderer, limits);
+
+        let up_total = up_node.bounds().y + up_node.bounds().height;
+
+        let down_node = self
+            .down
+            .as_widget_mut()
+            .layout(
+                &mut tree.children[1],
+                renderer,
+                &limits.max_height(size.height - up_total),
+            )
+            .move_to(Point::new(0.0, up_total));
+
+        let node = Node::with_children(size, vec![up_node, down_node]);
+
+        node
+    }
+
+    fn draw(
+        &self,
+        tree: &iced::advanced::widget::Tree,
+        renderer: &mut Renderer,
+        theme: &Theme,
+        style: &iced::advanced::renderer::Style,
+        layout: iced::advanced::Layout<'_>,
+        cursor: iced::advanced::mouse::Cursor,
+        viewport: &iced::Rectangle,
+    ) {
+        let mut children = layout.children();
+        let up = children.next().unwrap();
+        let down = children.next().unwrap();
+
+        // Draw the upper child
+        self.up.as_widget().draw(
+            &tree.children[0],
+            renderer,
+            theme,
+            style,
+            up,
+            cursor,
+            viewport,
+        );
+
+        // Draw the divider bar
+        let divider_bounds = Rectangle {
+            x: up.bounds().x,
+            y: up.bounds().y + up.bounds().height,
+            width: up.bounds().width,
+            height: 20.0,
+        };
+        renderer.fill_quad(
+            renderer::Quad {
+                bounds: divider_bounds,
+                border: border::rounded(0.0),
+                ..Default::default()
+            },
+            Color::from_rgb(0.5, 0.5, 0.2),
+        );
+
+        // Draw the lower child
+        self.down.as_widget().draw(
+            &tree.children[1],
+            renderer,
+            theme,
+            style,
+            down,
+            cursor,
+            viewport,
+        );
+    }
+
+    fn tag(&self) -> tree::Tag {
+        tree::Tag::of::<ResizableState>()
+    }
+
+    fn state(&self) -> tree::State {
+        tree::State::new(ResizableState)
+    }
+
+    fn children(&self) -> Vec<Tree> {
+        vec![Tree::new(&self.up), Tree::new(&self.down)]
+    }
+
+    fn diff(&self, tree: &mut Tree) {
+        tree.diff_children(&[&self.up, &self.down]);
+    }
+
+    fn update(
+        &mut self,
+        state: &mut Tree,
+        event: &Event,
+        layout: Layout<'_>,
+        cursor: iced::advanced::mouse::Cursor,
+        renderer: &Renderer,
+        clipboard: &mut dyn iced::advanced::Clipboard,
+        shell: &mut iced::advanced::Shell<'_, Message>,
+        viewport: &Rectangle,
+    ) {
+        let mut children = layout.children();
+
+        self.up.as_widget_mut().update(
+            &mut state.children[0],
+            event,
+            children.next().unwrap(),
+            cursor,
+            renderer,
+            clipboard,
+            shell,
+            viewport,
+        );
+
+        self.down.as_widget_mut().update(
+            &mut state.children[1],
+            event,
+            children.next().unwrap(),
+            cursor,
+            renderer,
+            clipboard,
+            shell,
+            viewport,
+        );
+    }
+
+    fn mouse_interaction(
+        &self,
+        tree: &Tree,
+        layout: Layout<'_>,
+        cursor: mouse::Cursor,
+        viewport: &Rectangle,
+        renderer: &Renderer,
+    ) -> mouse::Interaction {
+        let mut children = layout.children();
+
+        let up = self.up.as_widget().mouse_interaction(
+            &tree.children[0],
+            children.next().unwrap(),
+            cursor,
+            viewport,
+            renderer,
+        );
+
+        match up {
+            Interaction::None => {}
+            _ => return up,
+        }
+
+        let down = self.down.as_widget().mouse_interaction(
+            &tree.children[1],
+            children.next().unwrap(),
+            cursor,
+            viewport,
+            renderer,
+        );
+
+        match down {
+            Interaction::None => {}
+            _ => return down,
+        }
+
+        mouse::Interaction::default()
+    }
+}
+
+impl<'a, Message, Theme, Renderer> From<ResizableY<'a, Message, Theme, Renderer>>
+    for Element<'a, Message, Theme, Renderer>
+where
+    Message: Clone + 'a,
+    Theme: 'a,
+    Renderer: iced::advanced::Renderer + 'a,
+{
+    fn from(value: ResizableY<'a, Message, Theme, Renderer>) -> Self {
+        Self::new(value)
+    }
+}
+
+impl<'a, Message, Theme, Renderer> ResizableY<'a, Message, Theme, Renderer> {
+    pub fn new(
+        up: Element<'a, Message, Theme, Renderer>,
+        down: Element<'a, Message, Theme, Renderer>,
+    ) -> Self {
+        Self {
+            height: 500.,
+            hover_border: false,
+            dragging: false,
+            min_height: 400.,
+            max_height: 800.,
+            last_mouse_y: 0.0,
+            up,
+            down,
+        }
+    }
+
+    pub fn update(&mut self, message: ResizeableMessage) {
+        // dbg!(&self, &message);
+        match message {
+            ResizeableMessage::EventOccurred(event) => {
+                // We're only interested in mouse events here
+                match event {
+                    mouse::Event::CursorMoved { position } => {
+                        let y = position.y;
+                        self.last_mouse_y = y;
+                        // hover region: within 6 px of the current right border
+                        let near_border = (y - self.height).abs() <= 6.0;
+
+                        // if already dragging, update width according to cursor x
+                        if self.dragging {
+                            // clamp width between min and max
+                            let new_h = y.clamp(self.min_height as f32, self.max_height as f32);
+                            self.height = new_h.round();
+                            self.last_mouse_y = y;
+                            // keep hover true while dragging to show consistent cursor feedback
+                            self.hover_border = true;
+                        } else {
+                            // only update hover when not dragging
+                            self.hover_border = near_border;
+                        }
+                    }
+                    mouse::Event::ButtonPressed(mouse::Button::Left) => {
+                        let y = self.last_mouse_y;
+                        if (y - (self.height as f32)).abs() <= 6.0 {
+                            self.dragging = true;
+                        }
+                    }
+                    mouse::Event::ButtonReleased(mouse::Button::Left) => {
+                        // stop dragging
+                        if self.dragging {
+                            self.dragging = false;
+                        }
+                    }
+                    _ => {}
+                }
+            }
+            ResizeableMessage::UpdateHover(b) => {
+                self.hover_border = b;
+            }
+        }
+    }
+
+    pub fn view(&'a self) -> Element<'a, Message> {
+        let handle_width = 6_f32;
+
+        // handle visuals: when hovering or dragging, show thicker line
+        let handle: Element<'a, Message> = {
+            container(" ")
+                .height(Length::Fixed(handle_width))
+                .width(Length::Fill)
+                .style(|_| iced::widget::container::Style {
+                    text_color: Some(iced::Color::from_rgba(1., 1., 1., 0.7)),
+                    background: None,
+                    border: iced::Border {
+                        color: iced::Color::WHITE,
+                        width: 1.,
+                        radius: (PI / 2.).into(),
+                    },
+                    shadow: iced::Shadow::default(),
+                    snap: true,
+                })
+        }
+        .into();
+
+        // let up: Element<'a, Message> = column![self.up, handle]
+        //     .width(Fill)
+        //     .height(Length::Fixed(self.height))
+        //     .spacing(0)
+        //     .into();
+        //
+        // let content = column![up, self.down]
+        //     .width(Length::Fill)
+        //     .height(Length::Fill);
+
+        container("")
+            .width(Length::Fill)
+            .height(Length::Fill)
+            .into()
+    }
+}
+
 #[derive(Debug, Clone)]
 pub enum ResizeableMessage {
     /// A raw iced-native event so we can inspect mouse move/press/release
-    EventOccurred(iced::Event),
+    EventOccurred(iced::mouse::Event),
 
     /// TODO: update hover state when hovering sidebar with mouse
     UpdateHover(bool),

+ 76 - 35
src/state.rs

@@ -1,6 +1,10 @@
 pub mod pane;
 
-use std::f32::consts::PI;
+use std::{
+    collections::{BTreeMap, HashMap, HashSet},
+    error::Error,
+    f32::consts::PI,
+};
 
 use iced::{
     Element,
@@ -8,14 +12,15 @@ use iced::{
     Task,
     widget::{container, pane_grid, row, text},
 };
+use tracing::Instrument;
 
 use crate::{
     Message,
     data::TemplateWorkspace,
     db::{self, Workspace, WorkspaceEntryCreate},
     menu::{EntryMenuMessage, WorkspaceMenu, WorkspaceMenuMessage},
-    request::{self, RequestMessage},
-    resizeable::Resizable,
+    request::{self, HttpResponse, RequestMessage, ResponseMessage},
+    resizeable::{ResizableX, ResizableY},
     state::pane::Pane,
 };
 
@@ -34,14 +39,19 @@ pub struct AppState {
 
     pub(super) panes: Option<iced::widget::pane_grid::State<Pane>>,
 
-    pub focus: Option<pane_grid::Pane>,
+    pub(super) focus: Option<pane_grid::Pane>,
 
-    main: Resizable,
+    main: ResizableX,
 
     req_current: Option<i64>,
 
-    // HTTP client, also Arc and cheap to clone.
+    /// HTTP client, just an Arc so cheap to clone.
     http_client: reqwest::Client,
+
+    /// Latest response per request executed. Initially empty.
+    responses: HashMap<i64, HttpResponse>,
+    req_running: HashSet<i64>,
+    // req_views: BTreeMap<i64, ResizableY>,
 }
 
 impl Default for AppState {
@@ -72,24 +82,12 @@ impl AppState {
             ws_current: None,
             panes: None,
             focus: None,
-            main: Resizable::new(300., 100., 1000.),
+            main: ResizableX::new(300., 100., 1000.),
             req_current: None,
             http_client: reqwest::Client::new(),
-        }
-    }
-
-    pub fn view_panes<'a>(&'a self) -> Element<'a, Message> {
-        if let Some(panes) = self.panes.as_ref() {
-            // If a pane grid is present, it means a request is loaded.
-            let request = self
-                .ws_current
-                .as_ref()
-                .unwrap()
-                .get_request(self.req_current.unwrap());
-
-            self.view_pane_content(request, panes)
-        } else {
-            container("Select a request to begin").into()
+            responses: HashMap::new(),
+            req_running: HashSet::new(),
+            // req_views: BTreeMap::new(),
         }
     }
 
@@ -125,10 +123,6 @@ impl AppState {
 
         let main = self.main.view(side, main);
 
-        // let main = iced::widget::row![side, main]
-        //     .spacing(20)
-        //     .width(Length::FillPortion(3));
-
         return iced::widget::container(
             iced::widget::column![
                 menus,
@@ -141,6 +135,21 @@ impl AppState {
         .into();
     }
 
+    pub fn view_panes<'a>(&'a self) -> Element<'a, Message> {
+        if let Some(panes) = self.panes.as_ref() {
+            // If a pane grid is present, it means a request is loaded.
+            let request = self
+                .ws_current
+                .as_ref()
+                .unwrap()
+                .get_request(self.req_current.unwrap());
+
+            self.view_pane_content(request, panes)
+        } else {
+            container("Select a request to begin").into()
+        }
+    }
+
     pub fn update(&mut self, message: Message) -> Task<Message> {
         macro_rules! unwrap {
             ($result:ident, $msg:path) => {
@@ -164,7 +173,7 @@ impl AppState {
         }
 
         #[cfg(debug_assertions)]
-        if !matches!(message, Message::Resizable(_)) {
+        if !matches!(message, Message::Resizable(_)) && !matches!(message, Message::Response(_)) {
             tracing::debug!("Message: {message:#?}");
         }
 
@@ -300,6 +309,9 @@ impl AppState {
             }
             Message::RequestSelected(id) => {
                 self.req_current = Some(id);
+                // if !self.req_views.contains_key(&id) {
+                //     self.req_views.insert(id, ResizableY::default());
+                // }
                 let pane_state = iced::widget::pane_grid::State::new(Pane {
                     id,
                     is_pinned: false,
@@ -309,7 +321,12 @@ impl AppState {
             }
             Message::Noop => {}
             Message::Pane(msg) => self.update_panes(msg),
-            Message::Resizable(e) => self.main.update(e),
+            Message::Resizable(e) => {
+                self.main.update(e.clone());
+                if let Some(req) = self.req_current {
+                    // self.req_views.get_mut(&req).as_mut().unwrap().update(e);
+                }
+            }
             Message::Request(request_message) => match request_message {
                 RequestMessage::UrlUpdated(url) => {
                     let req = self
@@ -320,16 +337,29 @@ impl AppState {
                     req.url = url;
                 }
                 RequestMessage::Run(id) => {
+                    self.req_running.insert(id);
+
                     let req = self.ws_current.as_mut().unwrap().get_request(id);
 
                     return Task::perform(
-                        request::send(self.http_client.clone(), req.clone().try_into().unwrap()),
-                        |result| {
-                            match result {
-                                Ok(res) => println!("success: {res:?}"),
-                                Err(e) => println!("error: {e}"),
-                            };
-                            Message::Noop
+                        {
+                            let span = tracing::debug_span!("req");
+                            request::send(self.http_client.clone(), req.clone().try_into().unwrap())
+                                .instrument(span)
+                        },
+                        move |result| match result {
+                            Ok(res) => Message::Response(ResponseMessage::Success(id, res)),
+                            Err(e) => {
+                                tracing::error!("error sending request: {e}");
+                                let mut source = e.source();
+                                while let Some(e) = source {
+                                    tracing::error!("  - {e}");
+                                    source = e.source();
+                                }
+                                // TODO: requests can fail for various reasons, not just
+                                // during sending
+                                Message::Response(ResponseMessage::Error(id, e.to_string()))
+                            }
                         },
                     );
                 }
@@ -348,6 +378,17 @@ impl AppState {
                     }
                 }
             },
+            Message::Response(msg) => match msg {
+                ResponseMessage::Success(req_id, res) => {
+                    self.req_running.remove(&req_id);
+                    self.responses.insert(req_id, res);
+                    tracing::debug!("response inserted");
+                }
+                ResponseMessage::Error(req_id, e) => {
+                    self.req_running.remove(&req_id);
+                    tracing::info!("TODO: Display error for req {req_id}; {e}")
+                }
+            },
         }
         Task::none()
     }

+ 17 - 9
src/state/pane.rs

@@ -2,10 +2,10 @@ use iced::{
     Alignment::Center,
     Color, Element,
     Length::Fill,
-    widget::{PaneGrid, button, column, container, pane_grid, row, scrollable, text},
+    widget::{PaneGrid, button, container, pane_grid, row, text},
 };
 
-use crate::{AppState, Message, request::WorkspaceRequest};
+use crate::{AppState, Message, request::WorkspaceRequest, resizeable::ResizableY};
 
 const PANE_ID_COLOR_UNFOCUSED: Color = Color::from_rgb(0.7, 0.2, 0.2);
 const PANE_ID_COLOR_FOCUSED: Color = Color::from_rgb(0.2, 0.2, 0.2);
@@ -158,13 +158,21 @@ impl AppState {
                     style::title_bar_active
                 });
 
-            pane_grid::Content::new(iced::widget::responsive(move |size| request.view()))
-                .title_bar(title_bar)
-                .style(if is_focused {
-                    style::pane_focused
-                } else {
-                    style::pane_active
-                })
+            pane_grid::Content::new(iced::widget::responsive(move |_| {
+                let req = request.view_req();
+                let res = request.view_res(
+                    self.responses.get(&request.entry.id),
+                    self.req_running.contains(&request.entry.id),
+                );
+
+                Element::from(ResizableY::new(req, res)) // .explain(Color::from_rgb(1., 0., 0.))
+            }))
+            .title_bar(title_bar)
+            .style(if is_focused {
+                style::pane_focused
+            } else {
+                style::pane_active
+            })
         })
         .width(Fill)
         .height(Fill)