Forráskód Böngészése

Fix workspace menu

biblius 1 hónapja
szülő
commit
a2d603d41c
7 módosított fájl, 1050 hozzáadás és 472 törlés
  1. 1 1
      Cargo.toml
  2. 8 8
      migrations/20250922150745_init.up.sql
  3. 293 67
      src/data.rs
  4. 320 0
      src/db.rs
  5. 188 317
      src/main.rs
  6. 181 29
      src/menu.rs
  7. 59 50
      src/model.rs

+ 1 - 1
Cargo.toml

@@ -14,6 +14,6 @@ reqwest = "0.12.15"
 tokio = { version = "1.44.1", features = ["macros"] }
 nom = "8.0.0"
 iced_aw = { version = "0.12.0", features = ["menu", "quad"] }
-iced = { version = "0.13.1", features = ["tokio"] }
+iced = { version = "0.13.1", features = ["tokio", "debug"] }
 tracing = "0.1.41"
 tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }

+ 8 - 8
migrations/20250922150745_init.up.sql

@@ -1,17 +1,17 @@
 CREATE TABLE workspaces (
-    id INTEGER PRIMARY KEY,
+    id INTEGER PRIMARY KEY NOT NULL,
     name TEXT NOT NULL UNIQUE
 );
 
 CREATE TABLE workspace_envs (
-    id INTEGER PRIMARY KEY,
+    id INTEGER PRIMARY KEY NOT NULL,
     workspace_id INTEGER NOT NULL,
     name TEXT NOT NULL,
     FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE
 );
 
 CREATE TABLE workspace_env_variables (
-    id INTEGER PRIMARY KEY,
+    id INTEGER PRIMARY KEY NOT NULL,
     workspace_id INTEGER NOT NULL,
     env_id INTEGER NOT NULL,
     name TEXT NOT NULL,
@@ -24,7 +24,7 @@ CREATE TABLE workspace_env_variables (
 );
 
 CREATE TABLE workspace_entries (
-    id INTEGER PRIMARY KEY,
+    id INTEGER PRIMARY KEY NOT NULL,
     workspace_id INTEGER NOT NULL,
     parent_id INTEGER,
     name TEXT NOT NULL,
@@ -34,9 +34,9 @@ CREATE TABLE workspace_entries (
 );
 
 CREATE TABLE request_params (
-    id INTEGER PRIMARY KEY,
+    id INTEGER PRIMARY KEY NOT NULL,
     workspace_id INTEGER NOT NULL,
-    request_id UNIQUE INTEGER NOT NULL,
+    request_id INTEGER UNIQUE NOT NULL,
     method TEXT NOT NULL,
     url TEXT NOT NULL,
     FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE,
@@ -44,7 +44,7 @@ CREATE TABLE request_params (
 );
 
 CREATE TABLE request_bodies (
-    id INTEGER PRIMARY KEY,
+    id INTEGER PRIMARY KEY NOT NULL,
     request_id UNIQUE NOT NULL,
     content_type TEXT NOT NULL,
     body TEXT NOT NULL,
@@ -52,7 +52,7 @@ CREATE TABLE request_bodies (
 );
 
 CREATE TABLE request_headers (
-    id INTEGER PRIMARY KEY,
+    id INTEGER PRIMARY KEY NOT NULL,
     request_id INTEGER NOT NULL,
     name TEXT NOT NULL,
     value TEXT NOT NULL,

+ 293 - 67
src/data.rs

@@ -1,5 +1,9 @@
 //! 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},
@@ -7,11 +11,19 @@ use nom::{
     multi::many0,
     sequence::{preceded, separated_pair},
 };
-use std::collections::HashMap;
+use std::{
+    cell::RefCell,
+    collections::{HashMap, VecDeque},
+    rc::Rc,
+};
 
-use crate::model::{self, RequestHeader, RequestParams, WorkspaceEntry};
+use crate::{
+    Message,
+    db::{Workspace, WorkspaceEntry},
+    menu::{WorkspaceActionMenu, WorkspaceEntryActionMenu},
+    model::{WorkspaceCollection, WorkspaceEntryItem, WorkspaceRequest},
+};
 
-#[derive(Debug)]
 pub struct TemplateWorkspace {
     /// Workspace id.
     pub id: i64,
@@ -21,49 +33,276 @@ pub struct TemplateWorkspace {
 
     /// Workspace environment variables accessible by all
     /// child entries.
-    pub environments: HashMap<i64, TemplateWorkspaceEnvironment>,
+    pub environments: HashMap<i64, WorkspaceEnvironment>,
 
     /// Workspace entities, either directories or requests.
-    pub entries: HashMap<i64, TemplateEntry>,
+    entries: Vec<RCell<WorkspaceNode>>,
 
     /// Current working environment.
     pub env_current: Option<i64>,
 
     /// Current open request.
-    pub req_current: Option<RequestParams>,
+    req_current: Option<RequestNode>,
+
+    /// Indexes entry IDs directly to their nodes.
+    indexes: HashMap<i64, Rc<RefCell<WorkspaceNode>>>,
+
+    /// Workspace menus for adding entries.
+    pub menus: HashMap<i64, WorkspaceEntryActionMenu>,
 
-    indexes: HashMap<i64, TemplateEntry>,
+    pub menu: WorkspaceActionMenu,
 }
 
-impl From<model::Workspace> for TemplateWorkspace {
-    fn from(value: model::Workspace) -> Self {
+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,
+            name: value.name.clone(),
             environments: HashMap::new(),
-            entries: HashMap::new(),
+            entries: Vec::new(),
             env_current: None,
             req_current: None,
             indexes: HashMap::new(),
+            menus: HashMap::new(),
+            menu: WorkspaceActionMenu::new(value.id, value.name),
         }
     }
 }
 
 impl TemplateWorkspace {
-    pub fn update_entries(&mut self, entries: Vec<TemplateEntry>) {
+    pub fn view(&self) -> Element<'static, Message> {
+        match self.req_current {
+            Some(ref _req) => {
+                // TODO: Display request
+                // iced::widget::container("TODO: Request editor");
+            }
+
+            None => {
+                // TODO: Display workspace stuff
+                // iced::widget::container(sidebar)
+            }
+        };
+
+        let mut sidebar = vec![];
+        let mut visited = Vec::new();
+
+        self.sidebar_recursive(&mut sidebar, &self.entries, &mut visited, 0);
+
+        scrollable(
+            iced::widget::column![self.menu.view(), horizontal_space()]
+                .extend(sidebar)
+                .width(600),
+        )
+        .height(Length::Fill)
+        .into()
+    }
+
+    fn sidebar_recursive(
+        &self,
+        sidebar: &mut Vec<Element<'static, Message>>,
+        entries: &[RCell<WorkspaceNode>],
+        visited: &mut Vec<i64>,
+        indent: u16,
+    ) {
+        for entry in entries {
+            let entry = &*entry.borrow();
+            visited.push(entry.entry().id);
+            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);
+                }
+                WorkspaceNode::Request(req) => {
+                    sidebar.push(
+                        row![text(if req.request.entry.name.is_empty() {
+                            "New request".to_string()
+                        } else {
+                            req.request.entry.name.clone()
+                        })]
+                        .padding(Padding::ZERO.left(indent))
+                        .into(),
+                    );
+                }
+            }
+        }
+    }
+
+    pub fn insert_entry(&mut self, entry: WorkspaceEntryItem) {
+        let parent = entry.parent_id();
+
+        let id = entry.id();
+        let entry = Rc::new(RefCell::new(match entry {
+            WorkspaceEntryItem::Collection(col) => {
+                let entry = &col.entry;
+                self.menus.insert(
+                    entry.id,
+                    WorkspaceEntryActionMenu::new(entry.id, entry.name.clone()),
+                );
+                WorkspaceNode::Collection(CollectionNode {
+                    entry: col,
+                    parent: parent.as_ref().and_then(|p| self.indexes.get(p).cloned()),
+                    entries: vec![],
+                })
+            }
+            WorkspaceEntryItem::Request(req) => WorkspaceNode::Request(RequestNode {
+                parent: parent.as_ref().and_then(|p| self.indexes.get(p).cloned()),
+                request: req,
+            }),
+        }));
+
+        self.indexes.insert(id, entry.clone());
+
+        let Some(parent) = parent else {
+            self.entries.push(entry);
+            return;
+        };
+
+        let Some(parent) = self.indexes.get_mut(&parent) else {
+            return;
+        };
+
+        match &mut *parent.borrow_mut() {
+            WorkspaceNode::Collection(col) => col.entries.push(entry),
+            WorkspaceNode::Request(_) => {}
+        }
+    }
+
+    pub fn update_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,
+                    }),
+                }))
+            })
+            .collect::<Vec<_>>();
+
+        let mut roots = vec![];
+        let mut children = vec![];
+
+        // Index all entries
         for entry in entries.iter() {
-            // self.update_entry_recursive(entry.id(), entry);
+            self.indexes.insert(entry.borrow().id(), entry.clone());
+
+            if entry.borrow().entry().parent_id.is_some() {
+                children.push(entry.clone())
+            } else {
+                roots.push(entry.clone())
+            }
         }
-        self.entries = entries.into_iter().map(|e| (e.id(), e)).collect();
+
+        for child in children.iter() {
+            let parent = child.borrow().entry().parent_id.unwrap();
+            match &mut *self.indexes[&parent].borrow_mut() {
+                WorkspaceNode::Collection(col) => col.entries.push(child.clone()),
+                WorkspaceNode::Request(_) => {}
+            }
+        }
+
+        let mut menus = HashMap::new();
+
+        for entry in entries.iter() {
+            let entry = entry.borrow();
+
+            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);
+                }
+                WorkspaceNode::Request(_) => {}
+            }
+        }
+
+        self.entries = roots;
+        self.menus = menus;
+
+        tracing::debug!("Loaded workspace: {self:#?}");
     }
 
-    fn update_entry_recursive(&mut self, entry: &TemplateEntry) {
-        //self.indexes.insert(entry.id())
+    fn index_menus(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() {
+                WorkspaceNode::Collection(col) => {
+                    Self::index_menus(menus, col);
+                }
+                WorkspaceNode::Request(_) => {}
+            }
+        }
     }
 }
 
 #[derive(Debug, Clone)]
-pub struct TemplateWorkspaceEnvironment {
+pub struct WorkspaceEnvironment {
     pub id: i64,
 
     /// Workspace environment name.
@@ -81,68 +320,55 @@ pub struct TemplateEnvironmentVariable {
     pub secret: bool,
 }
 
-#[derive(Debug, Clone)]
-pub enum TemplateEntry {
-    Collection(TemplateCollection),
-    Request(TemplateRequest),
+pub type RCell<T> = Rc<RefCell<T>>;
+
+#[derive(Debug)]
+enum WorkspaceNode {
+    Collection(CollectionNode),
+    Request(RequestNode),
 }
 
-impl TemplateEntry {
-    pub fn id(&self) -> i64 {
+impl WorkspaceNode {
+    fn entry(&self) -> &WorkspaceEntry {
         match self {
-            TemplateEntry::Collection(c) => c.id,
-            TemplateEntry::Request(r) => r.id,
+            WorkspaceNode::Collection(c) => &c.entry.entry,
+            WorkspaceNode::Request(r) => &r.request.entry,
         }
     }
-}
 
-#[derive(Debug, Clone)]
-pub struct TemplateCollection {
-    /// Database ID of the workspace entry representing this collection.
-    pub id: i64,
+    fn set_parent(&mut self, parent: RCell<WorkspaceNode>) {
+        match self {
+            WorkspaceNode::Collection(col) => col.parent = Some(parent),
+            WorkspaceNode::Request(req) => req.parent = Some(parent),
+        }
+    }
 
-    /// Child collection entries.
-    pub entries: HashMap<i64, TemplateEntry>,
+    fn children(&self) -> &[RCell<WorkspaceNode>] {
+        match self {
+            WorkspaceNode::Collection(col) => col.entries.iter().as_slice(),
+            WorkspaceNode::Request(_) => &[],
+        }
+    }
 }
 
-#[derive(Debug, Clone)]
-pub struct TemplateRequest {
-    /// Database ID of the workspace entry representing this request.
-    pub id: i64,
-
-    /// Template display name
-    pub name: String,
-
-    /// 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>,
+#[derive(Debug)]
+struct CollectionNode {
+    entry: WorkspaceCollection,
+    parent: Option<RCell<WorkspaceNode>>,
+    entries: Vec<RCell<WorkspaceNode>>,
+}
 
-    /// HTTP header pairs.
-    pub headers: Vec<RequestHeader>,
+#[derive(Debug)]
+struct RequestNode {
+    parent: Option<RCell<WorkspaceNode>>,
+    request: WorkspaceRequest,
 }
 
-impl TemplateRequest {
-    pub fn from_params_and_headers(
-        entry: WorkspaceEntry,
-        params: model::RequestParams,
-        headers: Vec<RequestHeader>,
-    ) -> Self {
-        TemplateRequest {
-            id: entry.id,
-            name: entry.name,
-            method: params.method,
-            url: params.url,
-            body: params.body,
-            content_type: params.content_type,
-            headers: headers,
+impl WorkspaceNode {
+    pub fn id(&self) -> i64 {
+        match self {
+            WorkspaceNode::Collection(c) => c.entry.entry.id,
+            WorkspaceNode::Request(r) => r.request.entry.id,
         }
     }
 }

+ 320 - 0
src/db.rs

@@ -1,4 +1,90 @@
+use crate::{
+    data::{TemplateEnvironmentVariable, WorkspaceEnvironment},
+    model::{WorkspaceCollection, WorkspaceEntryItem, WorkspaceRequest},
+};
+use sqlx::Type;
 use sqlx::sqlite::SqlitePool;
+use std::collections::HashMap;
+
+#[derive(Debug, Clone)]
+pub struct Workspace {
+    pub id: i64,
+    pub name: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct WorkspaceEnv {
+    pub id: i64,
+    pub workspace_id: i64,
+    pub name: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct WorkspaceEnvVariable {
+    pub id: i64,
+    pub env_id: i64,
+    pub name: String,
+    pub value: Option<String>,
+    pub secret: bool,
+}
+
+#[derive(Debug, Clone)]
+pub struct WorkspaceEntry {
+    pub id: i64,
+    pub workspace_id: i64,
+    pub parent_id: Option<i64>,
+    pub name: String,
+    pub r#type: WorkspaceEntryType,
+}
+
+#[derive(Debug, Clone)]
+pub struct RequestParams {
+    /// ID of the workspace entry representing this request.
+    pub id: i64,
+    pub method: String,
+    pub url: String,
+    pub content_type: Option<String>,
+    pub body: Option<String>,
+}
+
+#[derive(Debug, Clone)]
+pub struct RequestHeader {
+    pub name: String,
+    pub value: String,
+}
+
+#[derive(Debug, Clone)]
+pub enum WorkspaceEntryCreate {
+    Collection {
+        name: String,
+        workspace_id: i64,
+        parent_id: Option<i64>,
+    },
+    Request {
+        name: String,
+        workspace_id: i64,
+        parent_id: Option<i64>,
+        method: String,
+        url: String,
+    },
+}
+
+#[derive(Debug, Clone, Copy, Type)]
+#[sqlx(type_name = "INTEGER")]
+pub enum WorkspaceEntryType {
+    Request,
+    Collection,
+}
+
+impl From<i64> for WorkspaceEntryType {
+    fn from(value: i64) -> Self {
+        match value {
+            0 => Self::Request,
+            1 => Self::Collection,
+            _ => panic!("unrecognized entry type: {value}"),
+        }
+    }
+}
 
 pub async fn init(url: &str) -> SqlitePool {
     let pool = SqlitePool::connect(url)
@@ -12,3 +98,237 @@ pub async fn init(url: &str) -> SqlitePool {
 
     pool
 }
+
+pub async fn create_workspace(db: SqlitePool, name: String) -> Result<Workspace, String> {
+    match sqlx::query_as!(
+        Workspace,
+        "INSERT INTO workspaces (name) VALUES (?) RETURNING id, name",
+        name
+    )
+    .fetch_one(&db)
+    .await
+    {
+        Ok(workspace) => Ok(workspace),
+        Err(e) => Err(e.to_string()),
+    }
+}
+
+pub async fn list_workspaces(db: SqlitePool) -> Result<Vec<Workspace>, String> {
+    match sqlx::query_as!(Workspace, "SELECT id, name FROM workspaces")
+        .fetch_all(&db)
+        .await
+    {
+        Ok(workspaces) => Ok(workspaces),
+        Err(e) => Err(e.to_string()),
+    }
+}
+
+pub async fn create_workspace_entry(
+    db: SqlitePool,
+    entry: WorkspaceEntryCreate,
+) -> Result<WorkspaceEntryItem, String> {
+    match entry {
+        WorkspaceEntryCreate::Collection {
+            name,
+            workspace_id,
+            parent_id,
+        } => {
+            let entry = sqlx::query_as!(
+                WorkspaceEntry,
+                r#"INSERT INTO workspace_entries(name, workspace_id, parent_id, type) VALUES (?, ?, ?, ?) 
+                   RETURNING id, workspace_id, parent_id, name, type"#,
+                name,
+                workspace_id,
+                parent_id,
+                1)
+            .fetch_one(&db).await.unwrap();
+
+            let collection = WorkspaceCollection { entry };
+
+            Ok(WorkspaceEntryItem::Collection(collection))
+        }
+        WorkspaceEntryCreate::Request {
+            name,
+            workspace_id,
+            parent_id,
+            method,
+            url,
+        } => {
+            let mut tx = db
+                .begin()
+                .await
+                .map_err(|e| format!("Failed to start transaction: {}", e))?;
+
+            let request = sqlx::query_as!(
+                WorkspaceEntry,
+                r#"INSERT INTO workspace_entries(name, workspace_id, parent_id, type) VALUES (?, ?, ?, ?) 
+                   RETURNING id, workspace_id, name, parent_id, type"#,
+                name,
+                workspace_id,
+                parent_id,
+                0)
+            .fetch_one(&mut *tx).await.unwrap();
+
+            sqlx::query!(
+                "INSERT INTO request_params(workspace_id, request_id, method, url) VALUES (?, ?, ?, ?)",
+                workspace_id,
+                request.id,
+                method,
+                url
+            )
+            .execute(&mut *tx)
+            .await
+            .unwrap();
+
+            tx.commit()
+                .await
+                .map_err(|e| format!("Failed to commit transaction: {}", e))?;
+
+            let request = WorkspaceRequest {
+                entry: request,
+                method,
+                url,
+                body: None,
+                content_type: None,
+                headers: vec![],
+            };
+
+            Ok(WorkspaceEntryItem::Request(request))
+        }
+    }
+}
+
+pub async fn get_workspace_entries(
+    db: SqlitePool,
+    workspace_id: i64,
+) -> Result<Vec<WorkspaceEntryItem>, String> {
+    let entries = sqlx::query_as!(
+        WorkspaceEntry,
+        "SELECT id, workspace_id, parent_id, name, type FROM workspace_entries WHERE workspace_id = ?",
+        workspace_id,
+    )
+    .fetch_all(&db)
+    .await
+    .map_err(|e| e.to_string())?;
+
+    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
+           FROM request_params rp
+           LEFT JOIN request_bodies rb ON rp.request_id = rb.request_id
+           WHERE workspace_id = ? 
+        "#,
+        workspace_id
+    )
+    .fetch_all(&db)
+    .await
+    .map_err(|e| e.to_string())?
+    .into_iter()
+    .map(|req| (req.id, req))
+    .collect();
+
+    let mut out: Vec<WorkspaceEntryItem> = vec![];
+
+    for entry in entries {
+        match entry.r#type {
+            WorkspaceEntryType::Request => {
+                let headers = sqlx::query_as!(
+                    RequestHeader,
+                    "SELECT name, value FROM request_headers WHERE request_id = ?",
+                    entry.id
+                )
+                .fetch_all(&db)
+                .await
+                .map_err(|e| e.to_string())?;
+
+                let Some(params) = request_params.remove(&entry.id) else {
+                    tracing::warn!("request {} has no params!", entry.id);
+                    continue;
+                };
+
+                let req = WorkspaceRequest::from_params_and_headers(entry, params, headers);
+                out.push(WorkspaceEntryItem::new_req(req));
+            }
+            WorkspaceEntryType::Collection => {
+                let col = WorkspaceCollection {
+                    entry: entry.clone(),
+                };
+                out.push(WorkspaceEntryItem::new_col(col));
+            }
+        }
+    }
+
+    Ok(out)
+}
+
+pub async fn get_environments(
+    db: SqlitePool,
+    workspace_id: i64,
+) -> Result<HashMap<i64, WorkspaceEnvironment>, String> {
+    let envs = get_workspace_envs(db.clone(), workspace_id).await?;
+
+    let mut out = HashMap::with_capacity(envs.len());
+
+    for env in envs {
+        let variables = get_workspace_env_variables(db.clone(), env.id).await?;
+
+        out.insert(
+            env.id,
+            WorkspaceEnvironment {
+                id: env.id,
+                name: env.name,
+                variables: variables
+                    .into_iter()
+                    .map(|v| {
+                        (
+                            v.name.clone(),
+                            TemplateEnvironmentVariable {
+                                id: v.id,
+                                name: v.name,
+                                value: v.value,
+                                secret: v.secret,
+                            },
+                        )
+                    })
+                    .collect(),
+            },
+        );
+    }
+
+    Ok(out)
+}
+
+pub async fn get_workspace_envs(
+    db: SqlitePool,
+    workspace_id: i64,
+) -> Result<Vec<WorkspaceEnv>, String> {
+    match sqlx::query_as!(
+        WorkspaceEnv,
+        "SELECT id, workspace_id, name FROM workspace_envs WHERE workspace_id = $1",
+        workspace_id
+    )
+    .fetch_all(&db)
+    .await
+    {
+        Ok(envs) => Ok(envs),
+        Err(e) => Err(e.to_string()),
+    }
+}
+
+pub async fn get_workspace_env_variables(
+    db: SqlitePool,
+    env_id: i64,
+) -> Result<Vec<WorkspaceEnvVariable>, String> {
+    match sqlx::query_as!(
+        WorkspaceEnvVariable,
+        "SELECT id, env_id, name, value, secret FROM workspace_env_variables WHERE env_id = $1",
+        env_id
+    )
+    .fetch_all(&db)
+    .await
+    {
+        Ok(envs) => Ok(envs),
+        Err(e) => Err(e.to_string()),
+    }
+}

+ 188 - 317
src/main.rs

@@ -3,25 +3,88 @@ mod db;
 mod menu;
 mod model;
 
+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 sqlx::SqlitePool;
 
-use crate::data::{
-    TemplateCollection, TemplateEntry, TemplateEnvironmentVariable, TemplateRequest,
-    TemplateWorkspace, TemplateWorkspaceEnvironment,
-};
-use crate::menu::{WorkspaceMenu, WorkspaceMenuMessage};
-use crate::model::{RequestParams, WorkspaceEntry, WorkspaceEntryType};
+use crate::data::{RCell, TemplateWorkspace, WorkspaceEnvironment};
+use crate::db::{Workspace, WorkspaceEntryCreate};
+use crate::menu::{EntryMenuMessage, WorkspaceMenu, WorkspaceMenuMessage};
+use crate::model::WorkspaceEntryItem;
 
 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())
+    }
+}
+
+#[derive(Debug, Clone)]
+enum Message {
+    AddWorkspace,
+    WorkspaceAdded(Workspace),
+    ReloadWorkspaces(Vec<Workspace>),
+    NewWsBufferContentChange(String),
+    Noop,
+    WorkspaceMenu(WorkspaceMenuMessage),
+
+    WorkspaceEnvsInit(HashMap<i64, WorkspaceEnvironment>),
+    WorkspaceEntriesInit(Vec<WorkspaceEntryItem>),
+
+    EntryMenu(EntryMenuMessage),
+    WorkspaceEntryCreated(WorkspaceEntryItem),
+}
+
 macro_rules! unwrap {
     ($result:ident, $msg:path) => {
         match $result {
@@ -44,11 +107,14 @@ macro_rules! unwrap {
 }
 
 fn update(state: &mut AppState, message: Message) -> Task<Message> {
+    tracing::debug!("Message: {message:#?}");
+
     match message {
-        Message::WorkspaceAdded => {
-            return Task::perform(list_workspaces(state.db.clone()), |ws| {
-                unwrap!(ws, Message::ReloadWorkspaces)
-            });
+        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;
@@ -58,359 +124,164 @@ fn update(state: &mut AppState, message: Message) -> Task<Message> {
                 return Task::none();
             }
             tracing::info!("Adding workspace: {}", state.ws_buffer);
-            return Task::perform(
-                create_workspace(state.db.clone(), std::mem::take(&mut state.ws_buffer)),
-                |_| Message::WorkspaceAdded,
-            );
+            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(|a, b| a.name.cmp(&b.name));
-            state.workspaces = ws.into_iter().map(TemplateWorkspace::from).collect();
+            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.swap_remove(i);
-                    let id = workspace.id;
+                    let workspace = state.workspaces[i].clone();
+
+                    let id = workspace.borrow().id;
+
                     state.ws_current = Some(workspace);
-                    return Task::perform(get_entries(state.db.clone(), id as i64), |entries| {
-                        unwrap!(entries, Message::WorkspaceEntriesLoaded)
-                    })
+
+                    return Task::perform(
+                        db::get_workspace_entries(state.db.clone(), id as i64),
+                        |entries| unwrap!(entries, Message::WorkspaceEntriesInit),
+                    )
                     .chain(Task::perform(
-                        get_environments(state.db.clone(), id as i64),
-                        |envs| unwrap!(envs, Message::WorkspaceEnvsLoaded),
+                        db::get_environments(state.db.clone(), id as i64),
+                        |envs| unwrap!(envs, Message::WorkspaceEnvsInit),
                     ));
                 }
                 _ => {}
             }
         }
-        Message::WorkspaceEnvsLoaded(envs) => {
-            let Some(workspace) = &mut state.ws_current else {
+        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::WorkspaceEntriesLoaded(items) => {
-            let Some(workspace) = &mut state.ws_current else {
+        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::Noop => {}
-    }
-    Task::none()
-}
-
-fn view(state: &AppState) -> Element<'_, Message> {
-    let menus = state.ws_menu.view(&state.workspaces, &state.ws_buffer);
+        Message::EntryMenu(msg) => match msg {
+            EntryMenuMessage::Add((menu_id, params)) => {
+                let Some(ws) = state.ws_current.as_ref() else {
+                    return Task::none();
+                };
 
-    let main = match state.ws_current {
-        Some(ref ws) => {
-            // let sidebar = column![text(ws.name)].width(Length::Fill);
-            match ws.req_current {
-                Some(ref _req) => {
-                    // TODO: Display request
-                    iced::widget::container("TODO: Request editor")
-                }
+                let ws_id = ws.borrow().id;
 
-                None => {
-                    // TODO: Display workspace stuff
-                    iced::widget::container("TODO: Workspace stuff")
+                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),
                 }
-            }
-        }
-        None => 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("", |w| &w.name)
-        ))]
-    ]
-    .into()
-}
-
-#[derive(Debug, Clone)]
-enum Message {
-    ReloadWorkspaces(Vec<model::Workspace>),
-    WorkspaceAdded,
-    AddWorkspace,
-    NewWsBufferContentChange(String),
-    Noop,
-    WorkspaceMenu(WorkspaceMenuMessage),
-
-    WorkspaceEnvsLoaded(HashMap<i64, TemplateWorkspaceEnvironment>),
-    WorkspaceEntriesLoaded(Vec<TemplateEntry>),
-}
-
-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<TemplateWorkspace>,
-    ws_buffer: String,
-    ws_menu: WorkspaceMenu,
-    ws_current: Option<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<TemplateWorkspace> = list_workspaces(db.clone())
-            .await
-            .unwrap()
-            .into_iter()
-            .map(TemplateWorkspace::from)
-            .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())
-    }
-}
-
-async fn create_workspace(db: SqlitePool, name: String) -> Result<model::Workspace, String> {
-    match sqlx::query_as!(
-        model::Workspace,
-        "INSERT INTO workspaces (name) VALUES (?) RETURNING id, name",
-        name
-    )
-    .fetch_one(&db)
-    .await
-    {
-        Ok(workspace) => Ok(workspace),
-        Err(e) => Err(e.to_string()),
-    }
-}
 
-async fn list_workspaces(db: SqlitePool) -> Result<Vec<model::Workspace>, String> {
-    match sqlx::query_as!(model::Workspace, "SELECT id, name FROM workspaces")
-        .fetch_all(&db)
-        .await
-    {
-        Ok(workspaces) => Ok(workspaces),
-        Err(e) => Err(e.to_string()),
-    }
-}
-
-async fn get_entries(db: SqlitePool, workspace_id: i64) -> Result<Vec<TemplateEntry>, String> {
-    let entries = sqlx::query_as!(
-        model::WorkspaceEntry,
-        "SELECT id, workspace_id, parent_id, name, type FROM workspace_entries WHERE workspace_id = ?",
-        workspace_id,
-    )
-    .fetch_all(&db)
-    .await
-    .map_err(|e| e.to_string())?;
-
-    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
-           FROM request_params rp
-           LEFT JOIN request_bodies rb ON rp.request_id = rb.request_id
-           WHERE workspace_id = ? 
-        "#,
-        workspace_id
-    )
-    .fetch_all(&db)
-    .await
-    .map_err(|e| e.to_string())?
-    .into_iter()
-    .map(|req| (req.id, req))
-    .collect();
-
-    let mut out: Vec<TemplateEntry> = vec![];
-    let mut children: HashMap<i64, Vec<TemplateCollection>> = HashMap::new();
-    let mut child_requests = HashMap::<i64, Vec<TemplateRequest>>::new();
-
-    for entry in entries {
-        match entry.r#type {
-            WorkspaceEntryType::Request => {
-                let headers = sqlx::query_as!(
-                    model::RequestHeader,
-                    "SELECT name, value FROM request_headers WHERE request_id = ?",
-                    entry.id
-                )
-                .fetch_all(&db)
-                .await
-                .map_err(|e| e.to_string())?;
-
-                let Some(params) = request_params.remove(&entry.id) else {
-                    tracing::warn!("request {} has no params!", entry.id);
-                    continue;
+                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,
+                    },
                 };
 
-                let parent_id = entry.parent_id;
-                let req = TemplateRequest::from_params_and_headers(entry, params, headers);
-                if let Some(parent) = parent_id {
-                    child_requests
-                        .entry(parent)
-                        .and_modify(|reqs| reqs.push(req.clone()))
-                        .or_insert(vec![req.clone()]);
-                } else {
-                    out.push(TemplateEntry::Request(req));
-                }
+                return Task::perform(
+                    db::create_workspace_entry(state.db.clone(), input),
+                    |entry| unwrap!(entry, Message::WorkspaceEntryCreated),
+                );
             }
-            WorkspaceEntryType::Collection => {
-                let col = TemplateCollection {
-                    id: entry.id,
-                    entries: HashMap::new(),
+            EntryMenuMessage::Dismiss(id) | EntryMenuMessage::Expand(id) => {
+                let Some(ws) = state.ws_current.as_ref() else {
+                    return Task::none();
                 };
-                if let Some(parent) = entry.parent_id {
-                    children
-                        .entry(parent)
-                        .and_modify(|p| p.push(col.clone()))
-                        .or_insert(vec![col]);
-                } else {
-                    out.push(TemplateEntry::Collection(col));
+
+                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),
                 }
             }
-        }
-    }
-
-    fn extend_recursive(
-        collection: &mut TemplateCollection,
-        children: &mut HashMap<i64, Vec<TemplateCollection>>,
-        requests: &mut HashMap<i64, Vec<TemplateRequest>>,
-    ) {
-        let id = collection.id as i64;
-        collection.entries.extend(
-            children
-                .remove(&id)
-                .unwrap_or_default()
-                .into_iter()
-                .map(|child| (child.id, TemplateEntry::Collection(child))),
-        );
-
-        collection.entries.extend(
-            requests
-                .remove(&id)
-                .unwrap_or_default()
-                .into_iter()
-                .map(|req| (req.id, TemplateEntry::Request(req))),
-        );
-
-        for entry in collection.entries.values_mut() {
-            let TemplateEntry::Collection(collection) = entry else {
-                continue;
+        },
+        Message::WorkspaceEntryCreated(entry) => {
+            let Some(ws) = state.ws_current.as_ref() else {
+                return Task::none();
             };
-            extend_recursive(collection, children, requests);
-        }
-    }
 
-    for entry in out.iter_mut() {
-        let TemplateEntry::Collection(collection) = entry else {
-            continue;
-        };
-        extend_recursive(collection, &mut children, &mut child_requests);
-    }
-
-    Ok(out)
-}
+            ws.borrow_mut().insert_entry(entry);
 
-async fn get_environments(
-    db: SqlitePool,
-    workspace_id: i64,
-) -> Result<HashMap<usize, TemplateWorkspaceEnvironment>, String> {
-    let envs = get_workspace_envs(db.clone(), workspace_id).await?;
-
-    let mut out = HashMap::with_capacity(envs.len());
-
-    for env in envs {
-        let variables = get_workspace_env_variables(db.clone(), env.id as i64).await?;
-
-        out.insert(
-            env.id as usize,
-            TemplateWorkspaceEnvironment {
-                id: env.id,
-                name: env.name,
-                variables: variables
-                    .into_iter()
-                    .map(|v| {
-                        (
-                            v.name.clone(),
-                            TemplateEnvironmentVariable {
-                                id: v.id,
-                                name: v.name,
-                                value: v.value,
-                                secret: v.secret,
-                            },
-                        )
-                    })
-                    .collect(),
-            },
-        );
+            tracing::info!("Workspace current: {:#?}", ws);
+        }
+        Message::Noop => {}
     }
-
-    Ok(out)
+    Task::none()
 }
 
-async fn get_workspace_envs(
-    db: SqlitePool,
-    workspace_id: i64,
-) -> Result<Vec<model::WorkspaceEnv>, String> {
-    match sqlx::query_as!(
-        model::WorkspaceEnv,
-        "SELECT id, workspace_id, name FROM workspace_envs WHERE workspace_id = $1",
-        workspace_id
-    )
-    .fetch_all(&db)
-    .await
-    {
-        Ok(envs) => Ok(envs),
-        Err(e) => Err(e.to_string()),
-    }
-}
+fn view(state: &AppState) -> Element<'_, Message> {
+    let menus = state.ws_menu.view(&state.workspaces, &state.ws_buffer);
 
-async fn get_workspace_env_variables(
-    db: SqlitePool,
-    env_id: i64,
-) -> Result<Vec<model::WorkspaceEnvVariable>, String> {
-    match sqlx::query_as!(
-        model::WorkspaceEnvVariable,
-        "SELECT id, env_id, name, value, secret FROM workspace_env_variables WHERE env_id = $1",
-        env_id
-    )
-    .fetch_all(&db)
-    .await
-    {
-        Ok(envs) => Ok(envs),
-        Err(e) => Err(e.to_string()),
+    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()
+        }
     }
 }

+ 181 - 29
src/menu.rs

@@ -1,11 +1,11 @@
+use std::cell::RefCell;
+use std::rc::Rc;
+
 use iced::Padding;
 use iced::widget::text;
 use iced::{
     Element, Length,
-    widget::{
-        Button, Column, Row, Text, button, column, horizontal_rule, row, scrollable,
-        text::Wrapping, text_input,
-    },
+    widget::{button, column, horizontal_rule, row, scrollable, text::Wrapping, text_input},
 };
 
 use iced_aw::{
@@ -15,6 +15,7 @@ use iced_aw::{
 
 use crate::Message;
 use crate::data::TemplateWorkspace;
+use crate::db::WorkspaceEntryType;
 
 #[derive(Clone, Debug)]
 pub enum WorkspaceMenuMessage {
@@ -42,36 +43,35 @@ impl WorkspaceMenu {
 
     pub fn view<'a>(
         &'a self,
-        workspaces: &'a [TemplateWorkspace],
+        workspaces: &'a [Rc<RefCell<TemplateWorkspace>>],
         new_ws_buffer: &'a str,
     ) -> Element<'a, Message> {
-        let underlay = Row::new().push(
-            Button::new(Text::new("Workspace"))
-                .on_press(Message::WorkspaceMenu(WorkspaceMenuMessage::Expand)),
+        let underlay = row!(
+            button("Workspace").on_press(Message::WorkspaceMenu(WorkspaceMenuMessage::Expand))
         );
 
-        let options = Column::new()
-            .push(column![text("Add"), horizontal_rule(8)].padding(Padding::ZERO.bottom(1)))
-            .push(
-                row![
-                    text_input("Workspace name", new_ws_buffer)
-                        .width(Length::FillPortion(11))
-                        .on_input(Message::NewWsBufferContentChange),
-                    button(text("+").center())
-                        .width(Length::Fill)
-                        .on_press(Message::AddWorkspace),
-                ]
-                .padding(Padding::ZERO.bottom(4)),
-            )
-            .push(column![text("Select"), horizontal_rule(8)].padding(Padding::ZERO.bottom(1)))
-            .extend(self.choices.iter().map(|choice| {
-                button(text(workspaces[*choice].name.clone()).wrapping(Wrapping::None))
-                    .on_press(Message::WorkspaceMenu(WorkspaceMenuMessage::Select(
-                        *choice,
-                    )))
+        let mut options = column![
+            column![text("Add"), horizontal_rule(8)].padding(Padding::ZERO.bottom(1)),
+            row![
+                text_input("Workspace name", new_ws_buffer)
+                    .width(Length::FillPortion(11))
+                    .on_input(Message::NewWsBufferContentChange),
+                button(text("+").center())
                     .width(Length::Fill)
-                    .into()
-            }));
+                    .on_press(Message::AddWorkspace),
+            ]
+            .padding(Padding::ZERO.bottom(4)),
+            column![text("Select"), horizontal_rule(8)].padding(Padding::ZERO.bottom(1))
+        ];
+
+        options = options.extend(self.choices.iter().map(|choice| {
+            button(text(workspaces[*choice].borrow().name.clone()).wrapping(Wrapping::None))
+                .on_press(Message::WorkspaceMenu(WorkspaceMenuMessage::Select(
+                    *choice,
+                )))
+                .width(Length::Fill)
+                .into()
+        }));
 
         let overlay = scrollable(options.spacing(0.5));
 
@@ -83,3 +83,155 @@ impl WorkspaceMenu {
             .into()
     }
 }
+
+#[derive(Debug, Clone)]
+pub struct WorkspaceActionMenu {
+    /// Workspace ID.
+    pub id: i64,
+    pub name: String,
+    pub expanded: bool,
+}
+
+impl WorkspaceActionMenu {
+    pub fn new(id: i64, name: String) -> Self {
+        Self {
+            id,
+            name,
+            expanded: false,
+        }
+    }
+
+    pub fn update(&mut self, message: &EntryMenuMessage) {
+        match message {
+            EntryMenuMessage::Add(_) => {
+                self.expanded = false;
+            }
+            EntryMenuMessage::Dismiss(_) => self.expanded = false,
+            EntryMenuMessage::Expand(_) => self.expanded = !self.expanded,
+        }
+    }
+
+    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)
+        ];
+
+        let add_col = AddEntryParams {
+            parent_id: None,
+            r#type: WorkspaceEntryType::Collection,
+        };
+
+        let add_req = AddEntryParams {
+            parent_id: None,
+            r#type: WorkspaceEntryType::Request,
+        };
+
+        let options = column![
+            button("Add collection")
+                .width(Length::Fill)
+                .padding(Padding::ZERO.bottom(1))
+                .on_press(Message::EntryMenu(EntryMenuMessage::Add((None, add_col)))),
+            button("Add request")
+                .width(Length::Fill)
+                .padding(Padding::ZERO.bottom(1))
+                .on_press(Message::EntryMenu(EntryMenuMessage::Add((None, add_req)))),
+        ];
+
+        DropDown::new(underlay, options, self.expanded)
+            .width(600)
+            .on_dismiss(Message::EntryMenu(EntryMenuMessage::Dismiss(None)))
+            .alignment(drop_down::Alignment::BottomEnd)
+            .offset(Offset::new(-100., 40.))
+            .into()
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct WorkspaceEntryActionMenu {
+    /// Entry ID.
+    pub id: i64,
+    pub name: String,
+    pub expanded: bool,
+}
+
+impl WorkspaceEntryActionMenu {
+    pub fn new(id: i64, name: String) -> Self {
+        Self {
+            id,
+            name,
+            expanded: false,
+        }
+    }
+
+    pub fn update(&mut self, message: &EntryMenuMessage) {
+        match message {
+            EntryMenuMessage::Add(_) => {
+                self.expanded = false;
+            }
+            EntryMenuMessage::Dismiss(_) => self.expanded = false,
+            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))))
+        ];
+
+        let add_col = AddEntryParams {
+            parent_id: Some(self.id),
+            r#type: WorkspaceEntryType::Collection,
+        };
+
+        let add_req = AddEntryParams {
+            parent_id: Some(self.id),
+            r#type: WorkspaceEntryType::Request,
+        };
+
+        let options = column![
+            button("Add collection")
+                .width(Length::Fill)
+                .padding(Padding::ZERO.bottom(1))
+                .on_press(Message::EntryMenu(EntryMenuMessage::Add((
+                    Some(self.id),
+                    add_col
+                )))),
+            button("Add request")
+                .width(Length::Fill)
+                .padding(Padding::ZERO.bottom(1))
+                .on_press(Message::EntryMenu(EntryMenuMessage::Add((
+                    Some(self.id),
+                    add_req
+                )))),
+        ];
+
+        DropDown::new(underlay, options, self.expanded)
+            .width(600)
+            .on_dismiss(Message::EntryMenu(EntryMenuMessage::Dismiss(Some(self.id))))
+            .alignment(drop_down::Alignment::BottomEnd)
+            .offset(Offset::new(-100., 40.))
+            .into()
+    }
+}
+
+/// Contains entry menu IDs.
+#[derive(Debug, Clone, Copy)]
+pub enum EntryMenuMessage {
+    Add((Option<i64>, AddEntryParams)),
+    Dismiss(Option<i64>),
+    Expand(Option<i64>),
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct AddEntryParams {
+    pub parent_id: Option<i64>,
+    pub r#type: WorkspaceEntryType,
+}

+ 59 - 50
src/model.rs

@@ -1,66 +1,75 @@
-use serde::{Deserialize, Serialize};
-use sqlx::prelude::Type;
+use crate::db::{RequestHeader, RequestParams, WorkspaceEntry};
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct Workspace {
-    pub id: i64,
-    pub name: String,
+#[derive(Debug, Clone)]
+pub struct WorkspaceCollection {
+    /// Database entry of the workspace entry representing this collection.
+    pub entry: WorkspaceEntry,
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct WorkspaceEnv {
-    pub id: i64,
-    pub workspace_id: i64,
-    pub name: String,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct WorkspaceEnvVariable {
-    pub id: i64,
-    pub env_id: i64,
-    pub name: String,
-    pub value: Option<String>,
-    pub secret: bool,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct WorkspaceEntry {
-    pub id: i64,
-    pub workspace_id: i64,
-    pub parent_id: Option<i64>,
-    pub name: String,
-    pub r#type: WorkspaceEntryType,
-}
+#[derive(Debug, Clone)]
+pub struct WorkspaceRequest {
+    /// Workspace entry representing this request.
+    pub entry: WorkspaceEntry,
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RequestParams {
-    /// ID of the workspace entry representing this request.
-    pub id: i64,
+    /// Request method.
     pub method: String,
+
+    /// The request URL
     pub url: String,
-    pub content_type: Option<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>,
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RequestHeader {
-    pub name: String,
-    pub value: String,
+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, Serialize, Deserialize, Type)]
-#[sqlx(type_name = "INTEGER")]
-pub enum WorkspaceEntryType {
-    Request = 0,
-    Collection = 1,
+#[derive(Debug, Clone)]
+pub enum WorkspaceEntryItem {
+    Collection(WorkspaceCollection),
+    Request(WorkspaceRequest),
 }
 
-impl From<i64> for WorkspaceEntryType {
-    fn from(value: i64) -> Self {
-        match value {
-            0 => Self::Request,
-            1 => Self::Collection,
-            _ => panic!("unrecognized entry type: {value}"),
+impl WorkspaceEntryItem {
+    pub fn new_req(req: WorkspaceRequest) -> Self {
+        Self::Request(req)
+    }
+
+    pub fn new_col(col: WorkspaceCollection) -> Self {
+        Self::Collection(col)
+    }
+
+    pub fn id(&self) -> i64 {
+        match self {
+            WorkspaceEntryItem::Collection(c) => c.entry.id,
+            WorkspaceEntryItem::Request(r) => r.entry.id,
+        }
+    }
+
+    pub fn parent_id(&self) -> Option<i64> {
+        match self {
+            WorkspaceEntryItem::Collection(c) => c.entry.parent_id,
+            WorkspaceEntryItem::Request(r) => r.entry.parent_id,
         }
     }
 }