Browse Source

Separate into modules

biblius 11 tháng trước cách đây
mục cha
commit
5066ba6efc
11 tập tin đã thay đổi với 1793 bổ sung và 1081 xóa
  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

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 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()
+        }
+    }
+}

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác