biblius преди 1 месец
родител
ревизия
5066ba6efc
променени са 11 файла, в които са добавени 1793 реда и са изтрити 1081 реда
  1. 221 326
      Cargo.lock
  2. 19 5
      Cargo.toml
  3. 130 406
      src/data.rs
  4. 9 15
      src/db.rs
  5. 20 254
      src/main.rs
  6. 53 36
      src/menu.rs
  7. 1 39
      src/model.rs
  8. 543 0
      src/request.rs
  9. 151 0
      src/resizeable.rs
  10. 354 0
      src/state.rs
  11. 292 0
      src/state/pane.rs

Файловите разлики са ограничени, защото са твърде много
+ 221 - 326
Cargo.lock


+ 19 - 5
Cargo.toml

@@ -9,11 +9,25 @@ edition = "2024"
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio"] }
-# boa_engine = "0.19.0"
-reqwest = "0.12.15"
-tokio = { version = "1.44.1", features = ["macros"] }
+reqwest = { version = "0.12.15", features = ["multipart"] }
+tokio = { version = "1.44.1", features = ["macros"] }                # boa_engine = "0.19.0"
 nom = "8.0.0"
-iced_aw = { version = "0.12.0", features = ["menu", "quad"] }
-iced = { version = "0.13.1", features = ["tokio", "debug"] }
+
+# 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_aw = { version = "0.13.0-dev", features = [
+	"menu",
+	"quad",
+	"tabs",
+	"tab_bar",
+] }
+
 tracing = "0.1.41"
 tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
+
+[patch.crates-io]
+iced = { git = "https://github.com/iced-rs/iced.git", rev = "26dfcb6d42a5cdd38ef0f75000484eaf4693f89a" }
+iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "26dfcb6d42a5cdd38ef0f75000484eaf4693f89a" }
+iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "26dfcb6d42a5cdd38ef0f75000484eaf4693f89a" }
+iced_aw = { git = "https://github.com/iced-rs/iced_aw.git", rev = "f49e058684b6e03a49b94c0ae6d9fb40db6ef2a2" }

+ 130 - 406
src/data.rs

@@ -1,29 +1,21 @@
 //! Application data.
 
-use iced::{
-    Element, Length, Padding,
-    widget::{horizontal_space, row, scrollable, text},
-};
-use nom::{
-    Parser,
-    bytes::complete::{tag, take_until, take_until1, take_while, take_while1},
-    character::complete::char,
-    multi::many0,
-    sequence::{preceded, separated_pair},
-};
-use std::{
-    cell::RefCell,
-    collections::{HashMap, VecDeque},
-    rc::Rc,
-};
-
 use crate::{
     Message,
     db::{Workspace, WorkspaceEntry},
     menu::{WorkspaceActionMenu, WorkspaceEntryActionMenu},
-    model::{WorkspaceCollection, WorkspaceEntryItem, WorkspaceRequest},
+    model::{WorkspaceCollection, WorkspaceEntryItem},
+    request::WorkspaceRequest,
+};
+use iced::widget::{button, rule, scrollable, space};
+use iced::{
+    Border, Color, Element, Length,
+    widget::{container, row, text},
 };
+use iced::{Shadow, widget};
+use std::{collections::HashMap, f32::consts::PI};
 
+#[derive(Debug)]
 pub struct TemplateWorkspace {
     /// Workspace id.
     pub id: i64,
@@ -36,16 +28,13 @@ pub struct TemplateWorkspace {
     pub environments: HashMap<i64, WorkspaceEnvironment>,
 
     /// Workspace entities, either directories or requests.
-    entries: Vec<RCell<WorkspaceNode>>,
+    roots: Vec<i64>,
 
     /// Current working environment.
     pub env_current: Option<i64>,
 
-    /// Current open request.
-    req_current: Option<RequestNode>,
-
     /// Indexes entry IDs directly to their nodes.
-    indexes: HashMap<i64, Rc<RefCell<WorkspaceNode>>>,
+    indexes: HashMap<i64, WorkspaceNode>,
 
     /// Workspace menus for adding entries.
     pub menus: HashMap<i64, WorkspaceEntryActionMenu>,
@@ -53,59 +42,14 @@ pub struct TemplateWorkspace {
     pub menu: WorkspaceActionMenu,
 }
 
-impl std::fmt::Debug for TemplateWorkspace {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        // Extract entry IDs to avoid recursive printing
-        let mut entries = HashMap::<i64, Vec<i64>>::new();
-
-        let mut queue = VecDeque::new();
-
-        for entry in self.entries.iter() {
-            queue.push_back(entry.clone());
-        }
-
-        while let Some(node) = queue.pop_front() {
-            let node = &*node.borrow();
-
-            entries
-                .entry(node.id())
-                .and_modify(|children| {
-                    children.extend(node.children().iter().map(|c| c.borrow().id()))
-                })
-                .or_insert(node.children().iter().map(|c| c.borrow().id()).collect());
-
-            queue.extend(node.children().iter().cloned());
-        }
-
-        let indexes = self
-            .indexes
-            .iter()
-            .map(|(k, v)| (k, v.borrow().entry().clone()))
-            .collect::<HashMap<_, _>>();
-
-        f.debug_struct("TemplateWorkspace")
-            .field("id", &self.id)
-            .field("name", &self.name)
-            .field("environments", &self.environments)
-            .field("entries", &entries)
-            .field("env_current", &self.env_current)
-            .field("req_current", &self.req_current)
-            .field("indexes", &indexes)
-            .field("menus", &self.menus)
-            .field("menu", &self.menu)
-            .finish()
-    }
-}
-
 impl From<Workspace> for TemplateWorkspace {
     fn from(value: Workspace) -> Self {
         Self {
             id: value.id,
             name: value.name.clone(),
             environments: HashMap::new(),
-            entries: Vec::new(),
+            roots: Vec::new(),
             env_current: None,
-            req_current: None,
             indexes: HashMap::new(),
             menus: HashMap::new(),
             menu: WorkspaceActionMenu::new(value.id, value.name),
@@ -114,75 +58,93 @@ impl From<Workspace> for TemplateWorkspace {
 }
 
 impl TemplateWorkspace {
-    pub fn view(&self) -> Element<'static, Message> {
-        match self.req_current {
-            Some(ref _req) => {
-                // TODO: Display request
-                // iced::widget::container("TODO: Request editor");
-            }
+    pub fn view<'a>(&'a self) -> Element<'a, Message> {
+        let mut sidebar = vec![];
 
-            None => {
-                // TODO: Display workspace stuff
-                // iced::widget::container(sidebar)
-            }
+        self.sidebar_recursive(&mut sidebar, &self.roots, 0.);
+
+        let workspaces = scrollable(
+            container(widget::column![self.menu.view(), rule::horizontal(8)].extend(sidebar))
+                .padding(10), //.width(Length::FillPortion(1)),
+        );
+
+        container(workspaces)
+            .style(|_| container::Style {
+                text_color: Some(Color::from_rgba(1., 0.5, 0.5, 0.7)),
+                background: None,
+                border: Border {
+                    color: Color::from_rgb(1., 0.5, 0.5),
+                    width: 1.,
+                    radius: (PI / 2.).into(),
+                },
+                shadow: Shadow::default(),
+                snap: true,
+            })
+            .height(Length::Fill)
+            .width(Length::FillPortion(1))
+            .padding(10)
+            .into()
+    }
+
+    pub fn get_request(&self, id: i64) -> &WorkspaceRequest {
+        let Some(WorkspaceNode::Request(req)) = self.indexes.get(&id) else {
+            panic!["no request for id {}", id];
         };
 
-        let mut sidebar = vec![];
-        let mut visited = Vec::new();
+        &req.request
+    }
 
-        self.sidebar_recursive(&mut sidebar, &self.entries, &mut visited, 0);
+    pub fn get_request_mut(&mut self, id: i64) -> &mut WorkspaceRequest {
+        let Some(WorkspaceNode::Request(req)) = self.indexes.get_mut(&id) else {
+            panic!["no request for id {}", id];
+        };
 
-        scrollable(
-            iced::widget::column![self.menu.view(), horizontal_space()]
-                .extend(sidebar)
-                .width(600),
-        )
-        .height(Length::Fill)
-        .into()
+        &mut req.request
     }
 
-    fn sidebar_recursive(
-        &self,
-        sidebar: &mut Vec<Element<'static, Message>>,
-        entries: &[RCell<WorkspaceNode>],
-        visited: &mut Vec<i64>,
-        indent: u16,
+    fn sidebar_recursive<'a>(
+        &'a self,
+        sidebar: &mut Vec<Element<'a, Message>>,
+        entries: &[i64],
+        indent: f32,
     ) {
-        for entry in entries {
-            let entry = &*entry.borrow();
-            visited.push(entry.entry().id);
+        for id in entries {
+            let Some(entry) = self.indexes.get(&id) else {
+                continue;
+            };
+
             match entry {
                 WorkspaceNode::Collection(col) => {
                     let id = col.entry.entry.id;
-                    let Some(menu) = self.menus.get(&id) else {
-                        tracing::warn!("Missing menu for collection {id}");
-                        continue;
-                    };
 
-                    let row = row![menu.view()].padding(Padding::ZERO.left(indent));
-                    sidebar.push(row.into());
-                    self.sidebar_recursive(sidebar, &col.entries, visited, indent + 10);
+                    sidebar.push(self.menus[&id].view(indent).into());
+
+                    if self.menus[&id].expanded {
+                        self.sidebar_recursive(sidebar, &col.entries, indent + 10.);
+                    }
                 }
                 WorkspaceNode::Request(req) => {
-                    sidebar.push(
-                        row![text(if req.request.entry.name.is_empty() {
+                    let row = row![
+                        space::horizontal().width(indent),
+                        button(text(if req.request.entry.name.is_empty() {
                             "New request".to_string()
                         } else {
                             req.request.entry.name.clone()
-                        })]
-                        .padding(Padding::ZERO.left(indent))
-                        .into(),
-                    );
+                        }))
+                        .on_press(Message::RequestSelected(req.request.entry.id))
+                        .width(Length::Fill)
+                    ];
+                    sidebar.push(row.into());
                 }
             }
         }
     }
 
     pub fn insert_entry(&mut self, entry: WorkspaceEntryItem) {
+        let id = entry.id();
         let parent = entry.parent_id();
 
-        let id = entry.id();
-        let entry = Rc::new(RefCell::new(match entry {
+        let entry = match entry {
             WorkspaceEntryItem::Collection(col) => {
                 let entry = &col.entry;
                 self.menus.insert(
@@ -191,20 +153,20 @@ impl TemplateWorkspace {
                 );
                 WorkspaceNode::Collection(CollectionNode {
                     entry: col,
-                    parent: parent.as_ref().and_then(|p| self.indexes.get(p).cloned()),
+                    parent,
                     entries: vec![],
                 })
             }
             WorkspaceEntryItem::Request(req) => WorkspaceNode::Request(RequestNode {
-                parent: parent.as_ref().and_then(|p| self.indexes.get(p).cloned()),
+                parent,
                 request: req,
             }),
-        }));
+        };
 
-        self.indexes.insert(id, entry.clone());
+        self.indexes.insert(id, entry);
 
         let Some(parent) = parent else {
-            self.entries.push(entry);
+            self.roots.push(id);
             return;
         };
 
@@ -212,49 +174,47 @@ impl TemplateWorkspace {
             return;
         };
 
-        match &mut *parent.borrow_mut() {
-            WorkspaceNode::Collection(col) => col.entries.push(entry),
+        match parent {
+            WorkspaceNode::Collection(col) => col.entries.push(id),
             WorkspaceNode::Request(_) => {}
         }
     }
 
-    pub fn update_entries(&mut self, entries: Vec<WorkspaceEntryItem>) {
+    pub fn init_entries(&mut self, entries: Vec<WorkspaceEntryItem>) {
         let entries = entries
             .into_iter()
-            .map(|entry| {
-                Rc::new(RefCell::new(match entry {
-                    WorkspaceEntryItem::Collection(col) => {
-                        WorkspaceNode::Collection(CollectionNode {
-                            entry: col,
-                            parent: None,
-                            entries: vec![],
-                        })
-                    }
-                    WorkspaceEntryItem::Request(req) => WorkspaceNode::Request(RequestNode {
-                        parent: None,
-                        request: req,
-                    }),
-                }))
+            .map(|entry| match entry {
+                WorkspaceEntryItem::Collection(col) => WorkspaceNode::Collection(CollectionNode {
+                    entry: col,
+                    parent: None,
+                    entries: vec![],
+                }),
+                WorkspaceEntryItem::Request(req) => WorkspaceNode::Request(RequestNode {
+                    parent: None,
+                    request: req,
+                }),
             })
             .collect::<Vec<_>>();
 
-        let mut roots = vec![];
-        let mut children = vec![];
+        let mut relations = vec![];
 
         // Index all entries
-        for entry in entries.iter() {
-            self.indexes.insert(entry.borrow().id(), entry.clone());
-
-            if entry.borrow().entry().parent_id.is_some() {
-                children.push(entry.clone())
+        for entry in entries {
+            if let Some(parent_id) = entry.entry().parent_id {
+                relations.push((entry.entry().id, parent_id))
             } else {
-                roots.push(entry.clone())
+                self.roots.push(entry.entry().id)
             }
+
+            self.indexes.insert(entry.id(), entry);
         }
 
-        for child in children.iter() {
-            let parent = child.borrow().entry().parent_id.unwrap();
-            match &mut *self.indexes[&parent].borrow_mut() {
+        for (child, parent) in relations.iter() {
+            let Some(parent) = self.indexes.get_mut(&parent) else {
+                continue;
+            };
+
+            match parent {
                 WorkspaceNode::Collection(col) => col.entries.push(child.clone()),
                 WorkspaceNode::Request(_) => {}
             }
@@ -262,38 +222,48 @@ impl TemplateWorkspace {
 
         let mut menus = HashMap::new();
 
-        for entry in entries.iter() {
-            let entry = entry.borrow();
+        for id in self.roots.iter() {
+            let Some(entry) = self.indexes.get(&id) else {
+                continue;
+            };
 
-            match &*entry {
+            match entry {
                 WorkspaceNode::Collection(col) => {
                     let entry = &col.entry.entry;
                     menus.insert(
                         entry.id,
                         WorkspaceEntryActionMenu::new(entry.id, entry.name.clone()),
                     );
-                    Self::index_menus(&mut menus, col);
+                    self.index_menus(&mut menus, &col);
                 }
                 WorkspaceNode::Request(_) => {}
             }
         }
 
-        self.entries = roots;
         self.menus = menus;
 
         tracing::debug!("Loaded workspace: {self:#?}");
     }
 
-    fn index_menus(menus: &mut HashMap<i64, WorkspaceEntryActionMenu>, col: &CollectionNode) {
+    fn index_menus(
+        &self,
+        menus: &mut HashMap<i64, WorkspaceEntryActionMenu>,
+        col: &CollectionNode,
+    ) {
         let entry = &col.entry.entry;
         menus.insert(
             entry.id,
             WorkspaceEntryActionMenu::new(entry.id, entry.name.clone()),
         );
-        for entry in col.entries.iter() {
-            match &*entry.borrow() {
+
+        for id in col.entries.iter() {
+            let Some(entry) = self.indexes.get(&id) else {
+                continue;
+            };
+
+            match entry {
                 WorkspaceNode::Collection(col) => {
-                    Self::index_menus(menus, col);
+                    self.index_menus(menus, &col);
                 }
                 WorkspaceNode::Request(_) => {}
             }
@@ -320,8 +290,6 @@ pub struct TemplateEnvironmentVariable {
     pub secret: bool,
 }
 
-pub type RCell<T> = Rc<RefCell<T>>;
-
 #[derive(Debug)]
 enum WorkspaceNode {
     Collection(CollectionNode),
@@ -335,32 +303,20 @@ impl WorkspaceNode {
             WorkspaceNode::Request(r) => &r.request.entry,
         }
     }
-
-    fn set_parent(&mut self, parent: RCell<WorkspaceNode>) {
-        match self {
-            WorkspaceNode::Collection(col) => col.parent = Some(parent),
-            WorkspaceNode::Request(req) => req.parent = Some(parent),
-        }
-    }
-
-    fn children(&self) -> &[RCell<WorkspaceNode>] {
-        match self {
-            WorkspaceNode::Collection(col) => col.entries.iter().as_slice(),
-            WorkspaceNode::Request(_) => &[],
-        }
-    }
 }
 
 #[derive(Debug)]
 struct CollectionNode {
     entry: WorkspaceCollection,
-    parent: Option<RCell<WorkspaceNode>>,
-    entries: Vec<RCell<WorkspaceNode>>,
+
+    /// ID of the parent node.
+    parent: Option<i64>,
+    entries: Vec<i64>,
 }
 
 #[derive(Debug)]
 struct RequestNode {
-    parent: Option<RCell<WorkspaceNode>>,
+    parent: Option<i64>,
     request: WorkspaceRequest,
 }
 
@@ -372,235 +328,3 @@ impl WorkspaceNode {
         }
     }
 }
-
-/// A fully deconstructed URL from a template request.
-/// Used as an intermediate step for populating the final URL with variables.
-#[derive(Debug)]
-pub struct TemplateRequestUrl<'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> TemplateRequestUrl<'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(TemplateRequestUrl {
-                            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(TemplateRequestUrl {
-                        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(TemplateRequestUrl {
-                            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(TemplateRequestUrl {
-                        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::{Segment, TemplateRequestUrl};
-
-    #[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 = TemplateRequestUrl::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 = TemplateRequestUrl::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 = TemplateRequestUrl::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 = TemplateRequestUrl::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 = TemplateRequestUrl::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 = TemplateRequestUrl::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);
-    }
-}

+ 9 - 15
src/db.rs

@@ -1,10 +1,11 @@
 use crate::{
     data::{TemplateEnvironmentVariable, WorkspaceEnvironment},
-    model::{WorkspaceCollection, WorkspaceEntryItem, WorkspaceRequest},
+    model::{WorkspaceCollection, WorkspaceEntryItem},
+    request::{ContentType, WorkspaceRequest},
 };
 use sqlx::Type;
 use sqlx::sqlite::SqlitePool;
-use std::collections::HashMap;
+use std::collections::{BTreeMap, HashMap};
 
 #[derive(Debug, Clone)]
 pub struct Workspace {
@@ -43,7 +44,7 @@ pub struct RequestParams {
     pub id: i64,
     pub method: String,
     pub url: String,
-    pub content_type: Option<String>,
+    pub content_type: Option<ContentType>,
     pub body: Option<String>,
 }
 
@@ -159,7 +160,7 @@ pub async fn create_workspace_entry(
                 .await
                 .map_err(|e| format!("Failed to start transaction: {}", e))?;
 
-            let request = sqlx::query_as!(
+            let entry = sqlx::query_as!(
                 WorkspaceEntry,
                 r#"INSERT INTO workspace_entries(name, workspace_id, parent_id, type) VALUES (?, ?, ?, ?) 
                    RETURNING id, workspace_id, name, parent_id, type"#,
@@ -172,7 +173,7 @@ pub async fn create_workspace_entry(
             sqlx::query!(
                 "INSERT INTO request_params(workspace_id, request_id, method, url) VALUES (?, ?, ?, ?)",
                 workspace_id,
-                request.id,
+                entry.id,
                 method,
                 url
             )
@@ -184,14 +185,7 @@ pub async fn create_workspace_entry(
                 .await
                 .map_err(|e| format!("Failed to commit transaction: {}", e))?;
 
-            let request = WorkspaceRequest {
-                entry: request,
-                method,
-                url,
-                body: None,
-                content_type: None,
-                headers: vec![],
-            };
+            let request = WorkspaceRequest::new(entry, method, url);
 
             Ok(WorkspaceEntryItem::Request(request))
         }
@@ -204,7 +198,7 @@ pub async fn get_workspace_entries(
 ) -> Result<Vec<WorkspaceEntryItem>, String> {
     let entries = sqlx::query_as!(
         WorkspaceEntry,
-        "SELECT id, workspace_id, parent_id, name, type FROM workspace_entries WHERE workspace_id = ?",
+        "SELECT id, workspace_id, parent_id, name, type FROM workspace_entries WHERE workspace_id = ? ORDER BY type DESC",
         workspace_id,
     )
     .fetch_all(&db)
@@ -214,7 +208,7 @@ pub async fn get_workspace_entries(
     let mut request_params: HashMap<i64, RequestParams> = sqlx::query_as!(
         RequestParams,
         r#"
-           SELECT rp.request_id as id, method as 'method!', url as 'url!', content_type, body
+           SELECT rp.request_id as id, method as 'method!', url as 'url!', content_type as "content_type: _", body
            FROM request_params rp
            LEFT JOIN request_bodies rb ON rp.request_id = rb.request_id
            WHERE workspace_id = ? 

+ 20 - 254
src/main.rs

@@ -2,75 +2,34 @@ mod data;
 mod db;
 mod menu;
 mod model;
+mod request;
+mod resizeable;
+mod state;
 
-use std::cell::RefCell;
 use std::collections::HashMap;
 use std::i64;
-use std::rc::Rc;
 
-use iced::widget::{row, text};
-use iced::{Element, Length, Task};
+use reqwest::Body;
+use reqwest::header::{self, HeaderMap, HeaderValue};
 
-use crate::data::{RCell, TemplateWorkspace, WorkspaceEnvironment};
-use crate::db::{Workspace, WorkspaceEntryCreate};
-use crate::menu::{EntryMenuMessage, WorkspaceMenu, WorkspaceMenuMessage};
+use crate::data::WorkspaceEnvironment;
+use crate::db::Workspace;
+use crate::menu::{EntryMenuMessage, WorkspaceMenuMessage};
 use crate::model::WorkspaceEntryItem;
+use crate::request::{ContentType, HttpRequestParameters, RequestMessage};
+use crate::resizeable::{Resizable, ResizeableMessage};
+use crate::state::AppState;
+use crate::state::pane::PaneMessage;
 
 fn main() -> iced::Result {
     tracing_subscriber::fmt::init();
-    iced::run("restEZ", update, view)
-}
-
-pub struct AppState {
-    /// Sqlite database. Just an Arc so cheap to clone.
-    db: sqlx::sqlite::SqlitePool,
-
-    /// Workspace buffer. Populated by entries only when necessary.
-    workspaces: Vec<Rc<RefCell<TemplateWorkspace>>>,
-    ws_buffer: String,
-
-    ws_menu: WorkspaceMenu,
-
-    ws_current: Option<RCell<TemplateWorkspace>>,
-}
-
-impl AppState {
-    pub async fn new() -> Self {
-        tracing::info!("Connecting to DB");
-        let db = db::init("sqlite:/home/biblius/codium/rusty/restez/restez.db").await;
-
-        let workspaces: Vec<_> = db::list_workspaces(db.clone())
-            .await
-            .unwrap()
-            .into_iter()
-            .map(|ws| Rc::new(RefCell::new(TemplateWorkspace::from(ws))))
-            .collect();
-
-        let ws_menu = WorkspaceMenu {
-            choices: (0..workspaces.len()).collect(),
-            expanded: false,
-        };
-
-        tracing::info!("State loaded");
-
-        Self {
-            db,
-            workspaces,
-            ws_buffer: String::with_capacity(64),
-            ws_menu,
-            ws_current: None,
-        }
-    }
-}
-
-impl Default for AppState {
-    fn default() -> Self {
-        iced::futures::executor::block_on(AppState::new())
-    }
+    iced::application(AppState::default, AppState::update, AppState::view)
+        .subscription(|_| Resizable::subscribe())
+        .run()
 }
 
 #[derive(Debug, Clone)]
-enum Message {
+pub enum Message {
     AddWorkspace,
     WorkspaceAdded(Workspace),
     ReloadWorkspaces(Vec<Workspace>),
@@ -83,205 +42,12 @@ enum Message {
 
     EntryMenu(EntryMenuMessage),
     WorkspaceEntryCreated(WorkspaceEntryItem),
-}
-
-macro_rules! unwrap {
-    ($result:ident, $msg:path) => {
-        match $result {
-            Ok(ws) => $msg(ws),
-            Err(e) => {
-                tracing::error!("{e}");
-                Message::Noop
-            }
-        }
-    };
-    ($result:ident, $msg:path, $err_msg:literal $(,)? $($args:expr),*) => {
-        match $result {
-            Ok(ws) => $msg(ws),
-            Err(e) => {
-                tracing::error!($err_msg, $($args),*);
-                Message::Noop
-            }
-        }
-    };
-}
 
-fn update(state: &mut AppState, message: Message) -> Task<Message> {
-    tracing::debug!("Message: {message:#?}");
+    RequestSelected(i64),
 
-    match message {
-        Message::WorkspaceAdded(ws) => {
-            tracing::info!("Workspace added: {:?}", ws);
-            state.workspaces.push(Rc::new(RefCell::new(ws.into())));
-            state.workspaces.sort_by_key(|ws| ws.borrow().name.clone());
-            state.ws_menu.choices = (0..state.workspaces.len()).collect()
-        }
-        Message::NewWsBufferContentChange(content) => {
-            state.ws_buffer = content;
-        }
-        Message::AddWorkspace => {
-            if state.ws_buffer.is_empty() {
-                return Task::none();
-            }
-            tracing::info!("Adding workspace: {}", state.ws_buffer);
-            let db = state.db.clone();
-            let ws_buffer = std::mem::take(&mut state.ws_buffer);
-            return Task::perform(db::create_workspace(db, ws_buffer), |ws| {
-                unwrap!(ws, Message::WorkspaceAdded)
-            });
-        }
-        Message::ReloadWorkspaces(mut ws) => {
-            ws.sort_by_key(|a| a.name.clone());
-            state.workspaces = ws
-                .into_iter()
-                .map(|ws| Rc::new(RefCell::new(TemplateWorkspace::from(ws))))
-                .collect();
-            state.ws_menu.choices = (0..state.workspaces.len()).collect()
-        }
-        Message::WorkspaceMenu(msg) => {
-            state.ws_menu.update(&msg);
-            match msg {
-                WorkspaceMenuMessage::Select(i) => {
-                    let workspace = state.workspaces[i].clone();
-
-                    let id = workspace.borrow().id;
-
-                    state.ws_current = Some(workspace);
-
-                    return Task::perform(
-                        db::get_workspace_entries(state.db.clone(), id as i64),
-                        |entries| unwrap!(entries, Message::WorkspaceEntriesInit),
-                    )
-                    .chain(Task::perform(
-                        db::get_environments(state.db.clone(), id as i64),
-                        |envs| unwrap!(envs, Message::WorkspaceEnvsInit),
-                    ));
-                }
-                _ => {}
-            }
-        }
-        Message::WorkspaceEnvsInit(envs) => {
-            let Some(ref workspace) = state.ws_current else {
-                tracing::warn!("Workspace env loaded, but no active workspace");
-                return Task::none();
-            };
-
-            let mut workspace = workspace.borrow_mut();
-
-            workspace.environments = envs;
-
-            if let Some(env) = workspace.environments.values().next() {
-                workspace.env_current = Some(env.id);
-            }
-        }
-        Message::WorkspaceEntriesInit(items) => {
-            let Some(ref workspace) = state.ws_current else {
-                tracing::warn!("Workspace entries loaded, but no active workspace");
-                return Task::none();
-            };
-
-            let mut workspace = workspace.borrow_mut();
-
-            workspace.update_entries(items);
-        }
-        Message::EntryMenu(msg) => match msg {
-            EntryMenuMessage::Add((menu_id, params)) => {
-                let Some(ws) = state.ws_current.as_ref() else {
-                    return Task::none();
-                };
-
-                let ws_id = ws.borrow().id;
-
-                match menu_id {
-                    Some(id) => {
-                        if let Some(menu) = ws.borrow_mut().menus.get_mut(&id) {
-                            tracing::debug!("Updating menu: {id} ({msg:?})");
-                            menu.update(&msg);
-                        }
-                    }
-                    None => ws.borrow_mut().menu.update(&msg),
-                }
-
-                let input = match params.r#type {
-                    db::WorkspaceEntryType::Request => WorkspaceEntryCreate::Request {
-                        name: String::new(),
-                        workspace_id: ws_id,
-                        parent_id: params.parent_id,
-                        method: "GET".to_string(),
-                        url: String::new(),
-                    },
-                    db::WorkspaceEntryType::Collection => WorkspaceEntryCreate::Collection {
-                        name: String::new(),
-                        workspace_id: ws_id,
-                        parent_id: params.parent_id,
-                    },
-                };
-
-                return Task::perform(
-                    db::create_workspace_entry(state.db.clone(), input),
-                    |entry| unwrap!(entry, Message::WorkspaceEntryCreated),
-                );
-            }
-            EntryMenuMessage::Dismiss(id) | EntryMenuMessage::Expand(id) => {
-                let Some(ws) = state.ws_current.as_ref() else {
-                    return Task::none();
-                };
-
-                match id {
-                    Some(id) => {
-                        if let Some(menu) = ws.borrow_mut().menus.get_mut(&id) {
-                            tracing::debug!("Updating menu: {id} ({msg:?})");
-                            menu.update(&msg);
-                        }
-                    }
-                    None => ws.borrow_mut().menu.update(&msg),
-                }
-            }
-        },
-        Message::WorkspaceEntryCreated(entry) => {
-            let Some(ws) = state.ws_current.as_ref() else {
-                return Task::none();
-            };
-
-            ws.borrow_mut().insert_entry(entry);
-
-            tracing::info!("Workspace current: {:#?}", ws);
-        }
-        Message::Noop => {}
-    }
-    Task::none()
-}
+    Pane(PaneMessage),
 
-fn view(state: &AppState) -> Element<'_, Message> {
-    let menus = state.ws_menu.view(&state.workspaces, &state.ws_buffer);
+    Resizable(ResizeableMessage),
 
-    match state.ws_current.as_ref().map(|ws| ws.borrow()) {
-        Some(ws) => {
-            let ws_name = ws.name.clone();
-            let main = ws.view();
-            return iced::widget::column![
-                menus,
-                main,
-                row![text(format!("Workspace: {}", ws_name))]
-            ]
-            .into();
-        }
-        None => {
-            let main = iced::widget::container("Select a workspace to start working.")
-                .center(Length::Fill)
-                .padding(10);
-            iced::widget::column![
-                menus,
-                main,
-                row![text(format!(
-                    "Workspace: {}",
-                    state
-                        .ws_current
-                        .as_ref()
-                        .map_or(String::new(), |w| w.borrow().name.clone())
-                ))]
-            ]
-            .into()
-        }
-    }
+    Request(RequestMessage),
 }

+ 53 - 36
src/menu.rs

@@ -1,11 +1,8 @@
-use std::cell::RefCell;
-use std::rc::Rc;
-
-use iced::Padding;
-use iced::widget::text;
+use iced::widget::{space, text};
+use iced::{Alignment, Padding};
 use iced::{
     Element, Length,
-    widget::{button, column, horizontal_rule, row, scrollable, text::Wrapping, text_input},
+    widget::{button, column, row, scrollable, text::Wrapping, text_input},
 };
 
 use iced_aw::{
@@ -14,8 +11,7 @@ use iced_aw::{
 };
 
 use crate::Message;
-use crate::data::TemplateWorkspace;
-use crate::db::WorkspaceEntryType;
+use crate::db::{Workspace, WorkspaceEntryType};
 
 #[derive(Clone, Debug)]
 pub enum WorkspaceMenuMessage {
@@ -43,15 +39,17 @@ impl WorkspaceMenu {
 
     pub fn view<'a>(
         &'a self,
-        workspaces: &'a [Rc<RefCell<TemplateWorkspace>>],
+        workspaces: &'a [Workspace],
         new_ws_buffer: &'a str,
     ) -> Element<'a, Message> {
-        let underlay = row!(
+        let underlay = row![
             button("Workspace").on_press(Message::WorkspaceMenu(WorkspaceMenuMessage::Expand))
-        );
+        ]
+        .padding(Padding::ZERO.left(2.5).top(2.5).bottom(2.5));
 
         let mut options = column![
-            column![text("Add"), horizontal_rule(8)].padding(Padding::ZERO.bottom(1)),
+            column![text("Add"), iced::widget::rule::horizontal(8)]
+                .padding(Padding::ZERO.bottom(1)),
             row![
                 text_input("Workspace name", new_ws_buffer)
                     .width(Length::FillPortion(11))
@@ -61,11 +59,12 @@ impl WorkspaceMenu {
                     .on_press(Message::AddWorkspace),
             ]
             .padding(Padding::ZERO.bottom(4)),
-            column![text("Select"), horizontal_rule(8)].padding(Padding::ZERO.bottom(1))
+            column![text("Select"), iced::widget::rule::horizontal(8)]
+                .padding(Padding::ZERO.bottom(1))
         ];
 
         options = options.extend(self.choices.iter().map(|choice| {
-            button(text(workspaces[*choice].borrow().name.clone()).wrapping(Wrapping::None))
+            button(text(&workspaces[*choice].name).wrapping(Wrapping::None))
                 .on_press(Message::WorkspaceMenu(WorkspaceMenuMessage::Select(
                     *choice,
                 )))
@@ -84,7 +83,7 @@ impl WorkspaceMenu {
     }
 }
 
-#[derive(Debug, Clone)]
+#[derive(Debug)]
 pub struct WorkspaceActionMenu {
     /// Workspace ID.
     pub id: i64,
@@ -107,17 +106,20 @@ impl WorkspaceActionMenu {
                 self.expanded = false;
             }
             EntryMenuMessage::Dismiss(_) => self.expanded = false,
-            EntryMenuMessage::Expand(_) => self.expanded = !self.expanded,
+            EntryMenuMessage::ExpandSubmenu(_) => self.expanded = !self.expanded,
+            EntryMenuMessage::Expand(_) => {}
         }
     }
 
-    pub fn view(&self) -> Element<'static, Message> {
-        let underlay = row![
-            text(self.name.clone()).width(Length::FillPortion(11)),
-            button("+")
-                .on_press(Message::EntryMenu(EntryMenuMessage::Expand(None)))
-                .width(Length::Fill)
-        ];
+    pub fn view<'a>(&'a self) -> Element<'a, Message> {
+        let underlay = iced::widget::container(
+            row![
+                text(&self.name),
+                iced::widget::space::horizontal().width(Length::Fill),
+                button("...").on_press(Message::EntryMenu(EntryMenuMessage::ExpandSubmenu(None)))
+            ]
+            .align_y(Alignment::Center),
+        );
 
         let add_col = AddEntryParams {
             parent_id: None,
@@ -149,11 +151,12 @@ impl WorkspaceActionMenu {
     }
 }
 
-#[derive(Debug, Clone)]
+#[derive(Debug)]
 pub struct WorkspaceEntryActionMenu {
     /// Entry ID.
     pub id: i64,
     pub name: String,
+    pub submenu_expanded: bool,
     pub expanded: bool,
 }
 
@@ -162,6 +165,7 @@ impl WorkspaceEntryActionMenu {
         Self {
             id,
             name,
+            submenu_expanded: false,
             expanded: false,
         }
     }
@@ -169,22 +173,34 @@ impl WorkspaceEntryActionMenu {
     pub fn update(&mut self, message: &EntryMenuMessage) {
         match message {
             EntryMenuMessage::Add(_) => {
-                self.expanded = false;
+                self.submenu_expanded = false;
             }
-            EntryMenuMessage::Dismiss(_) => self.expanded = false,
+            EntryMenuMessage::Dismiss(_) => self.submenu_expanded = false,
+            EntryMenuMessage::ExpandSubmenu(_) => self.submenu_expanded = !self.submenu_expanded,
             EntryMenuMessage::Expand(_) => self.expanded = !self.expanded,
         }
     }
 
-    pub fn view(&self) -> Element<'static, Message> {
-        let underlay = row![
-            text(if self.name.is_empty() {
-                "New Collection".to_string()
-            } else {
-                self.name.clone()
-            }),
-            button("+").on_press(Message::EntryMenu(EntryMenuMessage::Expand(Some(self.id))))
-        ];
+    pub fn view<'a>(&'a self, indent: f32) -> Element<'a, Message> {
+        let spacing = space::horizontal().width(indent);
+
+        let underlay = iced::widget::container(row![
+            spacing,
+            button(if self.expanded { "v" } else { ">" })
+                .on_press(Message::EntryMenu(EntryMenuMessage::Expand(Some(self.id)))),
+            row![
+                text(if self.name.is_empty() {
+                    "New Collection"
+                } else {
+                    &self.name
+                }),
+                space::horizontal().width(Length::Fill),
+                button("...").on_press(Message::EntryMenu(EntryMenuMessage::ExpandSubmenu(Some(
+                    self.id
+                ))))
+            ]
+            .align_y(Alignment::Center),
+        ]);
 
         let add_col = AddEntryParams {
             parent_id: Some(self.id),
@@ -213,7 +229,7 @@ impl WorkspaceEntryActionMenu {
                 )))),
         ];
 
-        DropDown::new(underlay, options, self.expanded)
+        DropDown::new(underlay, options, self.submenu_expanded)
             .width(600)
             .on_dismiss(Message::EntryMenu(EntryMenuMessage::Dismiss(Some(self.id))))
             .alignment(drop_down::Alignment::BottomEnd)
@@ -227,6 +243,7 @@ impl WorkspaceEntryActionMenu {
 pub enum EntryMenuMessage {
     Add((Option<i64>, AddEntryParams)),
     Dismiss(Option<i64>),
+    ExpandSubmenu(Option<i64>),
     Expand(Option<i64>),
 }
 

+ 1 - 39
src/model.rs

@@ -1,4 +1,4 @@
-use crate::db::{RequestHeader, RequestParams, WorkspaceEntry};
+use crate::{db::WorkspaceEntry, request::WorkspaceRequest};
 
 #[derive(Debug, Clone)]
 pub struct WorkspaceCollection {
@@ -6,44 +6,6 @@ pub struct WorkspaceCollection {
     pub entry: WorkspaceEntry,
 }
 
-#[derive(Debug, Clone)]
-pub struct WorkspaceRequest {
-    /// Workspace entry representing this request.
-    pub entry: WorkspaceEntry,
-
-    /// Request method.
-    pub method: String,
-
-    /// The request URL
-    pub url: String,
-
-    /// Request HTTP body.
-    pub body: Option<String>,
-
-    /// MIME type of body used for parsing.
-    pub content_type: Option<String>,
-
-    /// HTTP header pairs.
-    pub headers: Vec<RequestHeader>,
-}
-
-impl WorkspaceRequest {
-    pub fn from_params_and_headers(
-        entry: WorkspaceEntry,
-        params: RequestParams,
-        headers: Vec<RequestHeader>,
-    ) -> Self {
-        WorkspaceRequest {
-            entry,
-            method: params.method,
-            url: params.url,
-            body: params.body,
-            content_type: params.content_type,
-            headers: headers,
-        }
-    }
-}
-
 #[derive(Debug, Clone)]
 pub enum WorkspaceEntryItem {
     Collection(WorkspaceCollection),

+ 543 - 0
src/request.rs

@@ -0,0 +1,543 @@
+use std::{collections::HashMap, str::FromStr};
+
+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},
+};
+use reqwest::{
+    Body, Method,
+    header::{self, HeaderMap, HeaderValue},
+};
+use sqlx::error::BoxDynError;
+
+use crate::{
+    Message,
+    db::{RequestHeader, RequestParams, WorkspaceEntry},
+};
+
+pub const DEFAULT_HEADERS: &'static [(&'static str, &'static str)] = &[
+    ("user-agent", "RestEZ/0.0.1"),
+    ("accept", "*/*"),
+    ("accept-encoding", "gzip, defalte, br"),
+];
+
+pub async fn send(
+    client: reqwest::Client,
+    req: HttpRequestParameters,
+) -> Result<Option<String>, String> {
+    let HttpRequestParameters {
+        url,
+        method,
+        mut headers,
+        body,
+    } = req;
+
+    fn insert_ct_if_missing(headers: &mut HeaderMap, value: &'static str) {
+        if !headers.contains_key(header::CONTENT_TYPE) {
+            headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(value));
+        }
+    }
+
+    let body = match body {
+        Some(body) => {
+            match body.ty {
+                ContentType::Text => insert_ct_if_missing(&mut headers, "text/plain"),
+                ContentType::Json => insert_ct_if_missing(&mut headers, "application/json"),
+                ContentType::Xml => insert_ct_if_missing(&mut headers, "application/xml"),
+                // 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)
+        }
+        None => None,
+    };
+
+    let mut req = client.request(method, url).headers(headers);
+
+    if let Some(body) = body {
+        req = req.body(body)
+    }
+
+    let res = req.send().await;
+
+    let content = res.unwrap().text().await.unwrap();
+
+    tracing::debug!("content: {content}");
+
+    Ok(None)
+}
+
+#[derive(Debug, Clone)]
+pub struct WorkspaceRequest {
+    /// Workspace entry representing this request.
+    pub entry: WorkspaceEntry,
+
+    /// Request method.
+    pub method: String,
+
+    /// The request URL
+    pub url: String,
+
+    /// Request HTTP body.
+    pub body: Option<RequestBody>,
+
+    /// HTTP header names => values.
+    pub headers: HashMap<String, String>,
+
+    // View options
+    pub show_params: bool,
+    pub show_headers: bool,
+    pub show_body: bool,
+}
+
+impl WorkspaceRequest {
+    pub fn new(entry: WorkspaceEntry, method: String, url: String) -> Self {
+        Self {
+            entry,
+            method,
+            url,
+            body: None,
+            headers: HashMap::new(),
+            show_body: false,
+            show_params: false,
+            show_headers: false,
+        }
+    }
+
+    pub fn view<'a>(&'a self) -> Element<'a, Message> {
+        let url_input = row![
+            button(text(&self.method)).width(FillPortion(1)),
+            text_input("", &self.url)
+                .width(FillPortion(11))
+                .on_input(|content| Message::Request(RequestMessage::UrlUpdated(content))),
+            button("Send")
+                .width(FillPortion(1))
+                .on_press(Message::Request(RequestMessage::Run(self.entry.id)))
+        ];
+
+        // Section for path and query parameters
+        let mut param_section = column![
+            button(text(if self.show_params {
+                "v Parameters"
+            } else {
+                "> Parameters"
+            }))
+            .on_press(Message::Request(RequestMessage::SectionUpdate(
+                RequestSectionUpdate::Params
+            ))),
+            rule::horizontal(4)
+        ];
+
+        if self.show_params {
+            param_section = param_section.push(text("TODO: REQUEST PARAMS"));
+        }
+
+        // Section for custom headers
+        let mut header_section = column![
+            button(text(if self.show_params {
+                "v Headers"
+            } else {
+                "> Headers"
+            }))
+            .on_press(Message::Request(RequestMessage::SectionUpdate(
+                RequestSectionUpdate::Headers
+            ))),
+            rule::horizontal(4),
+        ];
+
+        if self.show_headers {
+            header_section = header_section.push(text("TODO: REQUEST HEADERS"));
+        }
+
+        let mut body_section = column![
+            button(text(if self.show_params { "v Body" } else { "> Body" })).on_press(
+                Message::Request(RequestMessage::SectionUpdate(RequestSectionUpdate::Body))
+            ),
+            rule::horizontal(4)
+        ];
+
+        if self.show_body {
+            body_section = body_section.push(text("TODO: REQUEST BODY"));
+        }
+
+        let content = column![url_input, param_section, header_section, body_section];
+
+        container(content).into()
+    }
+
+    pub fn from_params_and_headers(
+        entry: WorkspaceEntry,
+        params: RequestParams,
+        headers: Vec<RequestHeader>,
+    ) -> Self {
+        let body = match (params.body, params.content_type) {
+            (Some(content), Some(ty)) => Some(RequestBody { content, ty }),
+            (None, None) => None,
+            _ => panic!("body and content_type must both be present"),
+        };
+        WorkspaceRequest {
+            entry,
+            method: params.method,
+            url: params.url,
+            body,
+            headers: headers.into_iter().map(|h| (h.name, h.value)).collect(),
+            show_params: false,
+            show_headers: false,
+            show_body: false,
+        }
+    }
+}
+
+/// Finalized request parameters obtained from a [WorkspaceRequest].
+#[derive(Debug)]
+pub struct HttpRequestParameters {
+    pub url: String,
+    pub method: reqwest::Method,
+    pub headers: HeaderMap,
+    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;
+
+    fn try_from(value: WorkspaceRequest) -> Result<Self, String> {
+        let method = match Method::from_str(&value.method) {
+            Ok(m) => m,
+            Err(e) => return Err(e.to_string()),
+        };
+
+        let headers = match HeaderMap::try_from(&value.headers) {
+            Ok(h) => h,
+            Err(e) => return Err(e.to_string()),
+        };
+
+        Ok(Self {
+            url: value.url,
+            method,
+            headers,
+            body: value.body,
+        })
+    }
+}
+
+#[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)>,
+}
+
+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);
+    }
+}

+ 151 - 0
src/resizeable.rs

@@ -0,0 +1,151 @@
+use std::f32::consts::PI;
+
+use iced::{
+    Element, Event, Length, Subscription, mouse,
+    widget::{column, container, row, text},
+};
+
+use crate::Message;
+
+pub struct Resizable {
+    panel_width: f32,
+    hover_border: bool,
+    dragging: bool,
+
+    /// constraints
+    min_width: f32,
+    max_width: f32,
+
+    /// last known mouse position (used while dragging)
+    last_mouse_x: f32,
+}
+
+impl Resizable {
+    pub fn new(initial_width: f32, min_width: f32, max_width: f32) -> Self {
+        Self {
+            panel_width: initial_width,
+            hover_border: false,
+            dragging: false,
+            min_width,
+            max_width,
+            last_mouse_x: 0.0,
+        }
+    }
+
+    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;
+                            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;
+                            }
+                        }
+                        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::ButtonReleased(mouse::Button::Left) => {
+                            // stop dragging
+                            if self.dragging {
+                                self.dragging = false;
+                            }
+                        }
+                        _ => {}
+                    }
+                }
+            }
+            ResizeableMessage::UpdateHover(b) => {
+                self.hover_border = b;
+            }
+        }
+    }
+
+    pub fn view<'a, L, R, M>(&'a self, left: L, right: R) -> Element<'a, M>
+    where
+        L: Into<Element<'a, M>>,
+        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))
+                .height(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 left: Element<'a, M> = left.into();
+        let right: Element<'a, M> = right.into();
+
+        let left: Element<'a, M> = row![left, handle]
+            .width(Length::Fixed(self.panel_width))
+            .height(Length::Fill)
+            .spacing(0)
+            .into();
+
+        let content = row![left, right].width(Length::Fill).height(Length::Fill);
+
+        container(content)
+            .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),
+
+    /// TODO: update hover state when hovering sidebar with mouse
+    UpdateHover(bool),
+}

+ 354 - 0
src/state.rs

@@ -0,0 +1,354 @@
+pub mod pane;
+
+use std::f32::consts::PI;
+
+use iced::{
+    Element,
+    Length::{Fill, FillPortion},
+    Task,
+    widget::{container, pane_grid, row, text},
+};
+
+use crate::{
+    Message,
+    data::TemplateWorkspace,
+    db::{self, Workspace, WorkspaceEntryCreate},
+    menu::{EntryMenuMessage, WorkspaceMenu, WorkspaceMenuMessage},
+    request::{self, RequestMessage},
+    resizeable::Resizable,
+    state::pane::Pane,
+};
+
+pub struct AppState {
+    /// Sqlite database. Just an Arc so cheap to clone.
+    db: sqlx::sqlite::SqlitePool,
+
+    /// Workspace buffer. Populated by entries only when necessary.
+    workspaces: Vec<Workspace>,
+
+    ws_buffer: String,
+
+    ws_menu: WorkspaceMenu,
+
+    ws_current: Option<TemplateWorkspace>,
+
+    pub(super) panes: Option<iced::widget::pane_grid::State<Pane>>,
+
+    pub focus: Option<pane_grid::Pane>,
+
+    main: Resizable,
+
+    req_current: Option<i64>,
+
+    // HTTP client, also Arc and cheap to clone.
+    http_client: reqwest::Client,
+}
+
+impl Default for AppState {
+    fn default() -> Self {
+        iced::futures::executor::block_on(AppState::new())
+    }
+}
+
+impl AppState {
+    pub async fn new() -> Self {
+        tracing::info!("Connecting to DB");
+        let db = db::init("sqlite:/home/biblius/codium/rusty/restez/restez.db").await;
+
+        let workspaces: Vec<_> = db::list_workspaces(db.clone()).await.unwrap();
+
+        let ws_menu = WorkspaceMenu {
+            choices: (0..workspaces.len()).collect(),
+            expanded: false,
+        };
+
+        tracing::info!("State loaded");
+
+        Self {
+            db,
+            workspaces,
+            ws_buffer: String::with_capacity(64),
+            ws_menu,
+            ws_current: None,
+            panes: None,
+            focus: None,
+            main: Resizable::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()
+        }
+    }
+
+    pub fn view(&self) -> Element<'_, Message> {
+        let menus = self.ws_menu.view(&self.workspaces, &self.ws_buffer);
+
+        let current_ws_name = self
+            .ws_current
+            .as_ref()
+            .map(|ws| ws.name.clone())
+            .unwrap_or_default();
+
+        let main = self.view_panes();
+        let side = match self.ws_current.as_ref().map(|ws| ws) {
+            Some(ws) => ws.view(),
+            None => iced::widget::container("Select a workspace to start working.")
+                .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,
+                })
+                .padding(10)
+                .width(FillPortion(1))
+                .height(Fill)
+                .into(),
+        };
+
+        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,
+                main,
+                row![text(format!("Workspace: {}", current_ws_name))]
+            ]
+            .spacing(10)
+            .height(Fill),
+        )
+        .into();
+    }
+
+    pub fn update(&mut self, message: Message) -> Task<Message> {
+        macro_rules! unwrap {
+            ($result:ident, $msg:path) => {
+                match $result {
+                    Ok(ws) => $msg(ws),
+                    Err(e) => {
+                        tracing::error!("{e}");
+                        Message::Noop
+                    }
+                }
+            };
+            ($result:ident, $msg:path, $err_msg:literal $(,)? $($args:expr),*) => {
+                match $result {
+                    Ok(ws) => $msg(ws),
+                    Err(e) => {
+                        tracing::error!($err_msg, $($args),*);
+                        Message::Noop
+                    }
+                }
+            };
+        }
+
+        #[cfg(debug_assertions)]
+        if !matches!(message, Message::Resizable(_)) {
+            tracing::debug!("Message: {message:#?}");
+        }
+
+        match message {
+            Message::WorkspaceAdded(ws) => {
+                tracing::info!("Workspace added: {:?}", ws);
+                self.workspaces.push(ws);
+                self.workspaces.sort_by_key(|ws| ws.name.clone());
+                self.ws_menu.choices = (0..self.workspaces.len()).collect()
+            }
+            Message::NewWsBufferContentChange(content) => {
+                self.ws_buffer = content;
+            }
+            Message::AddWorkspace => {
+                if self.ws_buffer.is_empty() {
+                    return Task::none();
+                }
+                tracing::info!("Adding workspace: {}", self.ws_buffer);
+                let db = self.db.clone();
+                let ws_buffer = std::mem::take(&mut self.ws_buffer);
+                return Task::perform(db::create_workspace(db, ws_buffer), |ws| {
+                    unwrap!(ws, Message::WorkspaceAdded)
+                });
+            }
+            Message::ReloadWorkspaces(mut ws) => {
+                ws.sort_by_key(|a| a.name.clone());
+                self.workspaces = ws;
+                self.ws_menu.choices = (0..self.workspaces.len()).collect()
+            }
+            Message::WorkspaceMenu(msg) => {
+                self.ws_menu.update(&msg);
+                match msg {
+                    WorkspaceMenuMessage::Select(i) => {
+                        let workspace = self.workspaces[i].clone();
+
+                        let id = workspace.id;
+
+                        self.ws_current = Some(workspace.into());
+
+                        return Task::perform(
+                            db::get_workspace_entries(self.db.clone(), id as i64),
+                            |entries| unwrap!(entries, Message::WorkspaceEntriesInit),
+                        )
+                        .chain(Task::perform(
+                            db::get_environments(self.db.clone(), id as i64),
+                            |envs| unwrap!(envs, Message::WorkspaceEnvsInit),
+                        ));
+                    }
+                    _ => {}
+                }
+            }
+            Message::WorkspaceEnvsInit(envs) => {
+                let Some(ref mut workspace) = self.ws_current else {
+                    tracing::warn!("Workspace env loaded, but no active workspace");
+                    return Task::none();
+                };
+
+                workspace.environments = envs;
+
+                if let Some(env) = workspace.environments.values().next() {
+                    workspace.env_current = Some(env.id);
+                }
+            }
+            Message::WorkspaceEntriesInit(items) => {
+                let Some(ref mut workspace) = self.ws_current else {
+                    tracing::warn!("Workspace entries loaded, but no active workspace");
+                    return Task::none();
+                };
+
+                workspace.init_entries(items);
+            }
+            Message::EntryMenu(msg) => match msg {
+                EntryMenuMessage::Add((menu_id, params)) => {
+                    let Some(ref mut ws) = self.ws_current else {
+                        return Task::none();
+                    };
+
+                    let ws_id = ws.id;
+
+                    match menu_id {
+                        Some(id) => {
+                            if let Some(menu) = ws.menus.get_mut(&id) {
+                                tracing::debug!("Updating menu: {id} ({msg:?})");
+                                menu.update(&msg);
+                            }
+                        }
+                        None => ws.menu.update(&msg),
+                    }
+
+                    let input = match params.r#type {
+                        db::WorkspaceEntryType::Request => WorkspaceEntryCreate::Request {
+                            name: String::new(),
+                            workspace_id: ws_id,
+                            parent_id: params.parent_id,
+                            method: "GET".to_string(),
+                            url: String::new(),
+                        },
+                        db::WorkspaceEntryType::Collection => WorkspaceEntryCreate::Collection {
+                            name: String::new(),
+                            workspace_id: ws_id,
+                            parent_id: params.parent_id,
+                        },
+                    };
+
+                    return Task::perform(
+                        db::create_workspace_entry(self.db.clone(), input),
+                        |entry| unwrap!(entry, Message::WorkspaceEntryCreated),
+                    );
+                }
+                EntryMenuMessage::Expand(id)
+                | EntryMenuMessage::Dismiss(id)
+                | EntryMenuMessage::ExpandSubmenu(id) => {
+                    let Some(ref mut ws) = self.ws_current else {
+                        return Task::none();
+                    };
+
+                    match id {
+                        Some(id) => {
+                            if let Some(menu) = ws.menus.get_mut(&id) {
+                                tracing::debug!("Updating menu: {id} ({msg:?})");
+                                menu.update(&msg);
+                            }
+                        }
+                        None => ws.menu.update(&msg),
+                    }
+                }
+            },
+            Message::WorkspaceEntryCreated(entry) => {
+                if let Some(ref mut ws) = self.ws_current {
+                    ws.insert_entry(entry);
+                    tracing::info!("Workspace current: {:#?}", ws);
+                }
+            }
+            Message::RequestSelected(id) => {
+                self.req_current = Some(id);
+                let pane_state = iced::widget::pane_grid::State::new(Pane {
+                    id,
+                    is_pinned: false,
+                })
+                .0;
+                self.panes = Some(pane_state);
+            }
+            Message::Noop => {}
+            Message::Pane(msg) => self.update_panes(msg),
+            Message::Resizable(e) => self.main.update(e),
+            Message::Request(request_message) => match request_message {
+                RequestMessage::UrlUpdated(url) => {
+                    let req = self
+                        .ws_current
+                        .as_mut()
+                        .unwrap()
+                        .get_request_mut(self.req_current.unwrap());
+                    req.url = url;
+                }
+                RequestMessage::Run(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
+                        },
+                    );
+                }
+                RequestMessage::SectionUpdate(up) => {
+                    let req = self
+                        .ws_current
+                        .as_mut()
+                        .unwrap()
+                        .get_request_mut(self.req_current.unwrap());
+                    match up {
+                        request::RequestSectionUpdate::Params => req.show_params = !req.show_params,
+                        request::RequestSectionUpdate::Headers => {
+                            req.show_headers = !req.show_headers
+                        }
+                        request::RequestSectionUpdate::Body => req.show_body = !req.show_body,
+                    }
+                }
+            },
+        }
+        Task::none()
+    }
+}

+ 292 - 0
src/state/pane.rs

@@ -0,0 +1,292 @@
+use iced::{
+    Alignment::Center,
+    Color, Element,
+    Length::Fill,
+    widget::{PaneGrid, button, column, container, pane_grid, row, scrollable, text},
+};
+
+use crate::{AppState, Message, request::WorkspaceRequest};
+
+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);
+
+/// A pane representing a request buffer.
+#[derive(Clone, Copy)]
+pub struct Pane {
+    /// The ID of the Pane and the request it represents
+    pub id: i64,
+    pub is_pinned: bool,
+}
+
+impl Pane {
+    fn new(id: i64) -> Self {
+        Self {
+            id,
+            is_pinned: false,
+        }
+    }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum PaneMessage {
+    Split(pane_grid::Axis, pane_grid::Pane, i64),
+    SplitFocused(pane_grid::Axis, i64),
+    FocusAdjacent(pane_grid::Direction),
+    Clicked(pane_grid::Pane),
+    Dragged(pane_grid::DragEvent),
+    Resized(pane_grid::ResizeEvent),
+    TogglePin(pane_grid::Pane),
+    Maximize(pane_grid::Pane),
+    Restore,
+    Close(pane_grid::Pane),
+    CloseFocused,
+}
+
+impl AppState {
+    pub fn update_panes(&mut self, message: PaneMessage) {
+        let panes = self.panes.as_mut().unwrap();
+
+        match message {
+            PaneMessage::Split(axis, pane, id) => {
+                let result = panes.split(axis, pane, Pane::new(id));
+
+                if let Some((pane, _)) = result {
+                    self.focus = Some(pane);
+                }
+            }
+            PaneMessage::SplitFocused(axis, id) => {
+                if let Some(pane) = self.focus {
+                    let result = panes.split(axis, pane, Pane::new(id));
+
+                    if let Some((pane, _)) = result {
+                        self.focus = Some(pane);
+                    }
+                }
+            }
+            PaneMessage::FocusAdjacent(direction) => {
+                if let Some(pane) = self.focus {
+                    if let Some(adjacent) = panes.adjacent(pane, direction) {
+                        self.focus = Some(adjacent);
+                    }
+                }
+            }
+            PaneMessage::Clicked(pane) => {
+                self.focus = Some(pane);
+            }
+            PaneMessage::Resized(pane_grid::ResizeEvent { split, ratio }) => {
+                panes.resize(split, ratio);
+            }
+            PaneMessage::Dragged(pane_grid::DragEvent::Dropped { pane, target }) => {
+                panes.drop(pane, target);
+            }
+            PaneMessage::Dragged(_) => {}
+            PaneMessage::TogglePin(pane) => {
+                if let Some(Pane { is_pinned, .. }) = panes.get_mut(pane) {
+                    *is_pinned = !*is_pinned;
+                }
+            }
+            PaneMessage::Maximize(pane) => panes.maximize(pane),
+            PaneMessage::Restore => {
+                panes.restore();
+            }
+            PaneMessage::Close(pane) => {
+                if let Some((_, sibling)) = panes.close(pane) {
+                    self.focus = Some(sibling);
+                }
+            }
+            PaneMessage::CloseFocused => {
+                if let Some(pane) = self.focus {
+                    if let Some(Pane { is_pinned, .. }) = panes.get(pane) {
+                        if !is_pinned {
+                            if let Some((_, sibling)) = panes.close(pane) {
+                                self.focus = Some(sibling);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    pub fn view_pane_content<'a>(
+        &'a self,
+        request: &'a WorkspaceRequest,
+        panes: &'a pane_grid::State<Pane>,
+    ) -> Element<'a, Message> {
+        let focus = self.focus;
+
+        let total_panes = panes.len();
+
+        let pane_grid = PaneGrid::new(&panes, |id, pane, is_maximized| {
+            let is_focused = focus == Some(id);
+
+            let pin_button = button(text(if pane.is_pinned { "Unpin" } else { "Pin" }).size(14))
+                .on_press(Message::Pane(PaneMessage::TogglePin(id)))
+                .padding(3);
+
+            let title = row![
+                pin_button,
+                text(if request.entry.name.is_empty() {
+                    "Unnamed request"
+                } else {
+                    request.entry.name.as_str()
+                })
+                .color(if is_focused {
+                    PANE_ID_COLOR_FOCUSED
+                } else {
+                    PANE_ID_COLOR_UNFOCUSED
+                }),
+            ]
+            .spacing(5)
+            .align_y(Center);
+
+            let title_bar = pane_grid::TitleBar::new(title)
+                .controls(pane_grid::Controls::dynamic(
+                    view_controls(id, total_panes, pane.is_pinned, is_maximized, pane.id),
+                    button(text("X").size(14))
+                        .style(button::danger)
+                        .padding(3)
+                        .on_press_maybe(if total_panes > 1 && !pane.is_pinned {
+                            Some(Message::Pane(PaneMessage::Close(id)))
+                        } else {
+                            None
+                        }),
+                ))
+                .style(if is_focused {
+                    style::title_bar_focused
+                } else {
+                    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
+                })
+        })
+        .width(Fill)
+        .height(Fill)
+        .spacing(10)
+        .on_click(|p| Message::Pane(PaneMessage::Clicked(p)))
+        .on_drag(|p| Message::Pane(PaneMessage::Dragged(p)))
+        .on_resize(10, |p| Message::Pane(PaneMessage::Resized(p)));
+
+        container(pane_grid)
+            .width(Fill)
+            .height(Fill)
+            .padding(10)
+            .into()
+    }
+}
+
+fn view_controls<'a>(
+    pane: pane_grid::Pane,
+    total_panes: usize,
+    is_pinned: bool,
+    is_maximized: bool,
+    req_id: i64,
+) -> Element<'a, Message> {
+    let b = |label, message| {
+        button(text(label).width(Fill).align_x(Center).size(16))
+            .width(30)
+            .on_press(message)
+    };
+    row![
+        b(
+            "—",
+            Message::Pane(PaneMessage::Split(
+                pane_grid::Axis::Horizontal,
+                pane,
+                req_id
+            )),
+        ),
+        b(
+            "|",
+            Message::Pane(PaneMessage::Split(pane_grid::Axis::Vertical, pane, req_id)),
+        )
+    ]
+    .spacing(5)
+    .push(if total_panes > 1 {
+        let (content, message) = if is_maximized {
+            ("Restore", Message::Pane(PaneMessage::Restore))
+        } else {
+            ("Maximize", Message::Pane(PaneMessage::Maximize(pane)))
+        };
+
+        Some(
+            button(text(content).size(14))
+                .style(button::secondary)
+                .padding(3)
+                .on_press(message),
+        )
+    } else {
+        None
+    })
+    .push(
+        button(text("X").size(14))
+            .style(button::danger)
+            .width(30)
+            .on_press_maybe(if total_panes > 1 && !is_pinned {
+                Some(Message::Pane(PaneMessage::Close(pane)))
+            } else {
+                None
+            }),
+    )
+    .align_y(Center)
+    .into()
+}
+
+mod style {
+    use iced::widget::container;
+    use iced::{Border, Theme};
+
+    pub fn title_bar_active(theme: &Theme) -> container::Style {
+        let palette = theme.extended_palette();
+
+        container::Style {
+            text_color: Some(palette.background.strong.text),
+            background: Some(palette.background.strong.color.into()),
+            ..Default::default()
+        }
+    }
+
+    pub fn title_bar_focused(theme: &Theme) -> container::Style {
+        let palette = theme.extended_palette();
+
+        container::Style {
+            text_color: Some(palette.primary.strong.text),
+            background: Some(palette.primary.strong.color.into()),
+            ..Default::default()
+        }
+    }
+
+    pub fn pane_active(theme: &Theme) -> container::Style {
+        let palette = theme.extended_palette();
+
+        container::Style {
+            background: Some(palette.background.weak.color.into()),
+            border: Border {
+                width: 2.0,
+                color: palette.background.strong.color,
+                ..Border::default()
+            },
+            ..Default::default()
+        }
+    }
+
+    pub fn pane_focused(theme: &Theme) -> container::Style {
+        let palette = theme.extended_palette();
+
+        container::Style {
+            background: Some(palette.background.weak.color.into()),
+            border: Border {
+                width: 2.0,
+                color: palette.primary.strong.color,
+                ..Border::default()
+            },
+            ..Default::default()
+        }
+    }
+}

Някои файлове не бяха показани, защото твърде много файлове са промени