소스 검색

Start implementing custom resizable widget

biblius 1 개월 전
부모
커밋
132a0e09f1
11개의 변경된 파일1017개의 추가작업 그리고 508개의 파일을 삭제
  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)