biblius пре 1 месец
родитељ
комит
cbb60f71a3
8 измењених фајлова са 784 додато и 203 уклоњено
  1. 199 3
      Cargo.lock
  2. 1 0
      Cargo.toml
  3. 2 3
      migrations/20250922150745_init.down.sql
  4. 21 27
      migrations/20250922150745_init.up.sql
  5. 103 90
      src/data.rs
  6. 307 80
      src/main.rs
  7. 85 0
      src/menu.rs
  8. 66 0
      src/model.rs

+ 199 - 3
Cargo.lock

@@ -507,6 +507,19 @@ version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
 
+[[package]]
+name = "chrono"
+version = "0.4.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-link 0.2.0",
+]
+
 [[package]]
 name = "clipboard-win"
 version = "5.4.1"
@@ -1130,6 +1143,12 @@ dependencies = [
  "miniz_oxide",
 ]
 
+[[package]]
+name = "float_next_after"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
+
 [[package]]
 name = "flume"
 version = "0.11.1"
@@ -1759,6 +1778,30 @@ dependencies = [
  "windows-registry",
 ]
 
+[[package]]
+name = "iana-time-zone"
+version = "0.1.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core 0.62.1",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
 [[package]]
 name = "iced"
 version = "0.13.1"
@@ -1773,6 +1816,23 @@ dependencies = [
  "thiserror 1.0.69",
 ]
 
+[[package]]
+name = "iced_aw"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "582c517a94ce3205da98e9c10b26bb71aa36b7d7d084441d826dc912711d1bac"
+dependencies = [
+ "cfg-if",
+ "chrono",
+ "getrandom 0.3.3",
+ "iced",
+ "iced_fonts",
+ "itertools",
+ "num-format",
+ "num-traits",
+ "web-time",
+]
+
 [[package]]
 name = "iced_core"
 version = "0.13.2"
@@ -1793,6 +1853,15 @@ dependencies = [
  "web-time",
 ]
 
+[[package]]
+name = "iced_fonts"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df7deb0800a850ee25c8a42559f72c0f249e577feb3aad37b9b65dc1e517e52a"
+dependencies = [
+ "iced_core",
+]
+
 [[package]]
 name = "iced_futures"
 version = "0.13.2"
@@ -1834,6 +1903,7 @@ dependencies = [
  "iced_core",
  "iced_futures",
  "log",
+ "lyon_path",
  "once_cell",
  "raw-window-handle",
  "rustc-hash 2.1.1",
@@ -1897,6 +1967,7 @@ dependencies = [
  "iced_glyphon",
  "iced_graphics",
  "log",
+ "lyon",
  "once_cell",
  "rustc-hash 2.1.1",
  "thiserror 1.0.69",
@@ -2091,6 +2162,15 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
 [[package]]
 name = "itoa"
 version = "1.0.15"
@@ -2275,6 +2355,58 @@ version = "0.12.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
 
+[[package]]
+name = "lyon"
+version = "1.0.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbcb7d54d54c8937364c9d41902d066656817dce1e03a44e5533afebd1ef4352"
+dependencies = [
+ "lyon_algorithms",
+ "lyon_tessellation",
+]
+
+[[package]]
+name = "lyon_algorithms"
+version = "1.0.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4c0829e28c4f336396f250d850c3987e16ce6db057ffe047ce0dd54aab6b647"
+dependencies = [
+ "lyon_path",
+ "num-traits",
+]
+
+[[package]]
+name = "lyon_geom"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e16770d760c7848b0c1c2d209101e408207a65168109509f8483837a36cf2e7"
+dependencies = [
+ "arrayvec",
+ "euclid",
+ "num-traits",
+]
+
+[[package]]
+name = "lyon_path"
+version = "1.0.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aeca86bcfd632a15984ba029b539ffb811e0a70bf55e814ef8b0f54f506fdeb"
+dependencies = [
+ "lyon_geom",
+ "num-traits",
+]
+
+[[package]]
+name = "lyon_tessellation"
+version = "1.0.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3f586142e1280335b1bc89539f7c97dd80f08fc43e9ab1b74ef0a42b04aa353"
+dependencies = [
+ "float_next_after",
+ "lyon_path",
+ "num-traits",
+]
+
 [[package]]
 name = "malloc_buf"
 version = "0.0.6"
@@ -2493,6 +2625,16 @@ dependencies = [
  "zeroize",
 ]
 
+[[package]]
+name = "num-format"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3"
+dependencies = [
+ "arrayvec",
+ "itoa",
+]
+
 [[package]]
 name = "num-integer"
 version = "0.1.46"
@@ -3368,6 +3510,7 @@ name = "restez"
 version = "0.1.0"
 dependencies = [
  "iced",
+ "iced_aw",
  "nom",
  "reqwest",
  "serde",
@@ -5137,7 +5280,7 @@ version = "0.52.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
 dependencies = [
- "windows-core",
+ "windows-core 0.52.0",
  "windows-targets 0.52.6",
 ]
 
@@ -5150,6 +5293,41 @@ dependencies = [
  "windows-targets 0.52.6",
 ]
 
+[[package]]
+name = "windows-core"
+version = "0.62.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link 0.2.0",
+ "windows-result 0.4.0",
+ "windows-strings 0.5.0",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.106",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.106",
+]
+
 [[package]]
 name = "windows-link"
 version = "0.1.3"
@@ -5169,8 +5347,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
 dependencies = [
  "windows-link 0.1.3",
- "windows-result",
- "windows-strings",
+ "windows-result 0.3.4",
+ "windows-strings 0.4.2",
 ]
 
 [[package]]
@@ -5182,6 +5360,15 @@ dependencies = [
  "windows-link 0.1.3",
 ]
 
+[[package]]
+name = "windows-result"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f"
+dependencies = [
+ "windows-link 0.2.0",
+]
+
 [[package]]
 name = "windows-strings"
 version = "0.4.2"
@@ -5191,6 +5378,15 @@ dependencies = [
  "windows-link 0.1.3",
 ]
 
+[[package]]
+name = "windows-strings"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda"
+dependencies = [
+ "windows-link 0.2.0",
+]
+
 [[package]]
 name = "windows-sys"
 version = "0.45.0"

+ 1 - 0
Cargo.toml

@@ -13,6 +13,7 @@ sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio"] }
 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"] }
 tracing = "0.1.41"
 tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }

+ 2 - 3
migrations/20250922150745_init.down.sql

@@ -1,8 +1,7 @@
 DROP TABLE request_headers;
 DROP TABLE request_bodies;
-DROP TABLE http_requests;
-DROP TABLE collection_variables;
-DROP TABLE collections;
+DROP TABLE request_params;
+DROP TABLE workspace_entries;
 DROP TABLE workspace_env_variables;
 DROP TABLE workspace_envs;
 DROP TABLE workspaces;

+ 21 - 27
migrations/20250922150745_init.up.sql

@@ -1,66 +1,60 @@
 CREATE TABLE workspaces (
-    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    id INTEGER PRIMARY KEY,
     name TEXT NOT NULL UNIQUE
 );
 
 CREATE TABLE workspace_envs (
-    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    id INTEGER PRIMARY KEY,
     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 AUTOINCREMENT,
+    id INTEGER PRIMARY KEY,
+    workspace_id INTEGER NOT NULL,
     env_id INTEGER NOT NULL,
     name TEXT NOT NULL,
-    value TEXT NOT NULL,
+    value TEXT,
     secret BOOLEAN NOT NULL,
+    -- Optional collection scope
+    collection_id INTEGER,
+    FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE,
     FOREIGN KEY (env_id) REFERENCES workspace_envs (id) ON DELETE CASCADE
 );
 
-CREATE TABLE collections (
-    id INTEGER PRIMARY KEY AUTOINCREMENT,
+CREATE TABLE workspace_entries (
+    id INTEGER PRIMARY KEY,
     workspace_id INTEGER NOT NULL,
     parent_id INTEGER,
     name TEXT NOT NULL,
-    type TEXT NOT NULL,
-    FOREIGN KEY (parent_id) REFERENCES collections (id) ON DELETE CASCADE,
+    type INTEGER NOT NULL,
+    FOREIGN KEY (parent_id) REFERENCES workspace_entries (id) ON DELETE CASCADE,
     FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE
 );
 
-CREATE TABLE collection_variables (
-    id INTEGER PRIMARY KEY AUTOINCREMENT,
-    collection_id INTEGER NOT NULL,
-    name TEXT NOT NULL,
-    value TEXT NOT NULL,
-    FOREIGN KEY (collection_id) REFERENCES collections (id) ON DELETE CASCADE
-);
-
-CREATE TABLE http_requests (
-    id INTEGER PRIMARY KEY AUTOINCREMENT,
+CREATE TABLE request_params (
+    id INTEGER PRIMARY KEY,
     workspace_id INTEGER NOT NULL,
-    -- null if request is in workspace root
-    collection_id INTEGER,	
-    name TEXT NOT NULL,
+    request_id UNIQUE INTEGER NOT NULL,
     method TEXT NOT NULL,
     url TEXT NOT NULL,
-    FOREIGN KEY (collection_id) REFERENCES collections (id) ON DELETE CASCADE,
+    FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE,
     FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE
 );
 
 CREATE TABLE request_bodies (
-    id INTEGER PRIMARY KEY AUTOINCREMENT,
-    request_id INTEGER NOT NULL,
+    id INTEGER PRIMARY KEY,
+    request_id UNIQUE NOT NULL,
     content_type TEXT NOT NULL,
     body TEXT NOT NULL,
-    FOREIGN KEY (request_id) REFERENCES http_requests (id) ON DELETE CASCADE
+    FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE
 );
 
 CREATE TABLE request_headers (
-    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    id INTEGER PRIMARY KEY,
     request_id INTEGER NOT NULL,
     name TEXT NOT NULL,
     value TEXT NOT NULL,
-    FOREIGN KEY (request_id) REFERENCES http_requests (id) ON DELETE CASCADE
+    FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE
 );

+ 103 - 90
src/data.rs

@@ -1,3 +1,5 @@
+//! Application data.
+
 use nom::{
     Parser,
     bytes::complete::{tag, take_until, take_until1, take_while, take_while1},
@@ -7,62 +9,142 @@ use nom::{
 };
 use std::collections::HashMap;
 
+use crate::model::{self, RequestHeader, RequestParams, WorkspaceEntry};
+
+#[derive(Debug)]
 pub struct TemplateWorkspace {
-    pub id: usize,
+    /// Workspace id.
+    pub id: i64,
+
+    /// Workspace name.
+    pub name: String,
+
     /// Workspace environment variables accessible by all
     /// child entries.
-    pub environments: Vec<TemplateWorkspaceEnvironment>,
+    pub environments: HashMap<i64, TemplateWorkspaceEnvironment>,
+
+    /// Workspace entities, either directories or requests.
+    pub entries: HashMap<i64, TemplateEntry>,
+
+    /// Current working environment.
+    pub env_current: Option<i64>,
+
+    /// Current open request.
+    pub req_current: Option<RequestParams>,
+
+    indexes: HashMap<i64, TemplateEntry>,
+}
+
+impl From<model::Workspace> for TemplateWorkspace {
+    fn from(value: model::Workspace) -> Self {
+        Self {
+            id: value.id,
+            name: value.name,
+            environments: HashMap::new(),
+            entries: HashMap::new(),
+            env_current: None,
+            req_current: None,
+            indexes: HashMap::new(),
+        }
+    }
+}
 
-    pub entries: Vec<TemplateEntry>,
+impl TemplateWorkspace {
+    pub fn update_entries(&mut self, entries: Vec<TemplateEntry>) {
+        for entry in entries.iter() {
+            // self.update_entry_recursive(entry.id(), entry);
+        }
+        self.entries = entries.into_iter().map(|e| (e.id(), e)).collect();
+    }
+
+    fn update_entry_recursive(&mut self, entry: &TemplateEntry) {
+        //self.indexes.insert(entry.id())
+    }
 }
 
+#[derive(Debug, Clone)]
 pub struct TemplateWorkspaceEnvironment {
-    pub id: usize,
+    pub id: i64,
 
     /// Workspace environment name.
     pub name: String,
 
-    /// Variables in this environment.
+    /// Variables in this environment. Maps their names to the values.
     pub variables: HashMap<String, TemplateEnvironmentVariable>,
 }
 
+#[derive(Debug, Clone)]
 pub struct TemplateEnvironmentVariable {
-    pub id: usize,
+    pub id: i64,
     pub name: String,
-    pub default_value: String,
-    pub current_value: Option<String>,
+    pub value: Option<String>,
     pub secret: bool,
 }
 
+#[derive(Debug, Clone)]
 pub enum TemplateEntry {
-    Directory(TemplateDirectory),
+    Collection(TemplateCollection),
     Request(TemplateRequest),
 }
 
-pub struct TemplateDirectory {
-    pub id: usize,
-    /// Directory variables accessible only to child entries
-    /// of this directory.
-    pub variables: HashMap<String, TemplateEnvironmentVariable>,
+impl TemplateEntry {
+    pub fn id(&self) -> i64 {
+        match self {
+            TemplateEntry::Collection(c) => c.id,
+            TemplateEntry::Request(r) => r.id,
+        }
+    }
+}
 
-    pub entries: Vec<TemplateEntry>,
+#[derive(Debug, Clone)]
+pub struct TemplateCollection {
+    /// Database ID of the workspace entry representing this collection.
+    pub id: i64,
+
+    /// Child collection entries.
+    pub entries: HashMap<i64, TemplateEntry>,
 }
 
+#[derive(Debug, Clone)]
 pub struct TemplateRequest {
-    pub id: usize,
-
-    pub method: String,
+    /// 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,
 
-    /// Path parameters used to substitute path segments.
-    pub path_params: HashMap<String, String>,
+    /// Request HTTP body.
+    pub body: Option<String>,
 
-    pub headers: Vec<(String, String)>,
+    /// MIME type of body used for parsing.
+    pub content_type: Option<String>,
+
+    /// HTTP header pairs.
+    pub headers: Vec<RequestHeader>,
+}
+
+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,
+        }
+    }
 }
 
 /// A fully deconstructed URL from a template request.
@@ -204,75 +286,6 @@ pub enum Segment<'a> {
     Dynamic(&'a str),
 }
 
-pub mod model {
-    use serde::{Deserialize, Serialize};
-
-    #[derive(Debug, Clone, Serialize, Deserialize)]
-    pub struct Workspace {
-        pub id: i64,
-        pub name: String,
-    }
-
-    #[derive(Debug, Serialize, Deserialize)]
-    pub struct WorkspaceEnv {
-        pub id: i64,
-        pub workspace_id: i64,
-        pub name: String,
-    }
-
-    #[derive(Debug, Serialize, Deserialize)]
-    pub struct WorkspaceEnvVariable {
-        pub id: i64,
-        pub env_id: i64,
-        pub name: String,
-        pub value: String,
-        pub secret: bool,
-    }
-
-    #[derive(Debug, Serialize, Deserialize)]
-    pub struct Collection {
-        pub id: i64,
-        pub workspace_id: i64,
-        pub parent_id: Option<i64>,
-        pub name: String,
-        pub r#type: String, // `type` is a reserved keyword in Rust
-    }
-
-    #[derive(Debug, Serialize, Deserialize)]
-    pub struct CollectionVariable {
-        pub id: i64,
-        pub collection_id: i64,
-        pub name: String,
-        pub value: String,
-    }
-
-    #[derive(Debug, Serialize, Deserialize)]
-    pub struct HttpRequest {
-        pub id: i64,
-        pub workspace_id: i64,
-        pub collection_id: Option<i64>,
-        pub name: String,
-        pub method: String,
-        pub url: String,
-    }
-
-    #[derive(Debug, Serialize, Deserialize)]
-    pub struct RequestBody {
-        pub id: i64,
-        pub request_id: i64,
-        pub content_type: String,
-        pub body: String,
-    }
-
-    #[derive(Debug, Serialize, Deserialize)]
-    pub struct RequestHeader {
-        pub id: i64,
-        pub request_id: i64,
-        pub name: String,
-        pub value: String,
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::{Segment, TemplateRequestUrl};

+ 307 - 80
src/main.rs

@@ -1,29 +1,56 @@
 mod data;
 mod db;
+mod menu;
+mod model;
 
-use iced::widget::{button, text, text_input};
-use iced::{Element, Task};
+use std::collections::HashMap;
+use std::i64;
+
+use iced::widget::{row, text};
+use iced::{Element, Length, Task};
 use sqlx::SqlitePool;
 
-use crate::model::Workspace;
+use crate::data::{
+    TemplateCollection, TemplateEntry, TemplateEnvironmentVariable, TemplateRequest,
+    TemplateWorkspace, TemplateWorkspaceEnvironment,
+};
+use crate::menu::{WorkspaceMenu, WorkspaceMenuMessage};
+use crate::model::{RequestParams, WorkspaceEntry, WorkspaceEntryType};
 
 fn main() -> iced::Result {
     tracing_subscriber::fmt::init();
-    iced::run("RestEZ", update, view)
+    iced::run("restEZ", update, view)
+}
+
+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> {
     match message {
         Message::WorkspaceAdded => {
-            return Task::perform(list_workspaces(state.db.clone()), |ws| match ws {
-                Ok(ws) => Message::ReloadWorkspaces(ws),
-                Err(e) => {
-                    tracing::error!("Error loading workspaces {e}");
-                    Message::Noop
-                }
+            return Task::perform(list_workspaces(state.db.clone()), |ws| {
+                unwrap!(ws, Message::ReloadWorkspaces)
             });
         }
-        Message::ContentChange(content) => {
+        Message::NewWsBufferContentChange(content) => {
             state.ws_buffer = content;
         }
         Message::AddWorkspace => {
@@ -36,8 +63,48 @@ fn update(state: &mut AppState, message: Message) -> Task<Message> {
                 |_| Message::WorkspaceAdded,
             );
         }
-        Message::ReloadWorkspaces(ws) => {
-            state.workspaces = ws;
+        Message::ReloadWorkspaces(mut ws) => {
+            ws.sort_by(|a, b| a.name.cmp(&b.name));
+            state.workspaces = ws.into_iter().map(TemplateWorkspace::from).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;
+                    state.ws_current = Some(workspace);
+                    return Task::perform(get_entries(state.db.clone(), id as i64), |entries| {
+                        unwrap!(entries, Message::WorkspaceEntriesLoaded)
+                    })
+                    .chain(Task::perform(
+                        get_environments(state.db.clone(), id as i64),
+                        |envs| unwrap!(envs, Message::WorkspaceEnvsLoaded),
+                    ));
+                }
+                _ => {}
+            }
+        }
+        Message::WorkspaceEnvsLoaded(envs) => {
+            let Some(workspace) = &mut state.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::WorkspaceEntriesLoaded(items) => {
+            let Some(workspace) = &mut state.ws_current else {
+                tracing::warn!("Workspace entries loaded, but no active workspace");
+                return Task::none();
+            };
+
+            workspace.update_entries(items);
         }
         Message::Noop => {}
     }
@@ -45,33 +112,61 @@ fn update(state: &mut AppState, message: Message) -> Task<Message> {
 }
 
 fn view(state: &AppState) -> Element<'_, Message> {
-    let add_new: Element<_> = button(text("Add")).on_press(Message::AddWorkspace).into();
+    let menus = state.ws_menu.view(&state.workspaces, &state.ws_buffer);
 
-    let input: Element<_> = text_input("Workspace name", &state.ws_buffer)
-        .on_input(Message::ContentChange)
-        .into();
+    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 mut column = iced::widget::column![add_new, input];
-    for workspace in state.workspaces.iter() {
-        column = column.extend(vec![text(workspace.name.clone()).into()]);
+                None => {
+                    // TODO: Display workspace stuff
+                    iced::widget::container("TODO: Workspace stuff")
+                }
+            }
+        }
+        None => iced::widget::container("Select a workspace to start working."),
     }
-    column.into()
+    .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<Workspace>),
+    ReloadWorkspaces(Vec<model::Workspace>),
     WorkspaceAdded,
     AddWorkspace,
-    ContentChange(String),
+    NewWsBufferContentChange(String),
     Noop,
+    WorkspaceMenu(WorkspaceMenuMessage),
+
+    WorkspaceEnvsLoaded(HashMap<i64, TemplateWorkspaceEnvironment>),
+    WorkspaceEntriesLoaded(Vec<TemplateEntry>),
 }
 
 pub struct AppState {
     /// Sqlite database. Just an Arc so cheap to clone.
-    pub db: sqlx::sqlite::SqlitePool,
-    pub workspaces: Vec<model::Workspace>,
-    pub ws_buffer: String,
+    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 {
@@ -79,12 +174,26 @@ impl AppState {
         tracing::info!("Connecting to DB");
         let db = db::init("sqlite:/home/biblius/codium/rusty/restez/restez.db").await;
 
-        let workspaces = list_workspaces(db.clone()).await.unwrap();
+        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,
         }
     }
 }
@@ -119,71 +228,189 @@ async fn list_workspaces(db: SqlitePool) -> Result<Vec<model::Workspace>, String
     }
 }
 
-pub mod model {
-    use serde::{Deserialize, Serialize};
+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())?;
 
-    #[derive(Debug, Clone, Serialize, Deserialize)]
-    pub struct Workspace {
-        pub id: i64,
-        pub name: 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();
 
-    #[derive(Debug, Serialize, Deserialize)]
-    pub struct WorkspaceEnv {
-        pub id: i64,
-        pub workspace_id: i64,
-        pub name: String,
-    }
+    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())?;
 
-    #[derive(Debug, Serialize, Deserialize)]
-    pub struct WorkspaceEnvVariable {
-        pub id: i64,
-        pub env_id: i64,
-        pub name: String,
-        pub value: String,
-        pub secret: bool,
+                let Some(params) = request_params.remove(&entry.id) else {
+                    tracing::warn!("request {} has no params!", entry.id);
+                    continue;
+                };
+
+                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));
+                }
+            }
+            WorkspaceEntryType::Collection => {
+                let col = TemplateCollection {
+                    id: entry.id,
+                    entries: HashMap::new(),
+                };
+                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));
+                }
+            }
+        }
     }
 
-    #[derive(Debug, Serialize, Deserialize)]
-    pub struct Collection {
-        pub id: i64,
-        pub workspace_id: i64,
-        pub parent_id: Option<i64>,
-        pub name: String,
-        pub r#type: String, // `type` is a reserved keyword in Rust
+    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;
+            };
+            extend_recursive(collection, children, requests);
+        }
     }
 
-    #[derive(Debug, Serialize, Deserialize)]
-    pub struct CollectionVariable {
-        pub id: i64,
-        pub collection_id: i64,
-        pub name: String,
-        pub value: String,
+    for entry in out.iter_mut() {
+        let TemplateEntry::Collection(collection) = entry else {
+            continue;
+        };
+        extend_recursive(collection, &mut children, &mut child_requests);
     }
 
-    #[derive(Debug, Serialize, Deserialize)]
-    pub struct HttpRequest {
-        pub id: i64,
-        pub workspace_id: i64,
-        pub collection_id: Option<i64>,
-        pub name: String,
-        pub method: String,
-        pub url: String,
+    Ok(out)
+}
+
+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(),
+            },
+        );
     }
 
-    #[derive(Debug, Serialize, Deserialize)]
-    pub struct RequestBody {
-        pub id: i64,
-        pub request_id: i64,
-        pub content_type: String,
-        pub body: String,
+    Ok(out)
+}
+
+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()),
     }
+}
 
-    #[derive(Debug, Serialize, Deserialize)]
-    pub struct RequestHeader {
-        pub id: i64,
-        pub request_id: i64,
-        pub name: String,
-        pub value: String,
+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()),
     }
 }

+ 85 - 0
src/menu.rs

@@ -0,0 +1,85 @@
+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,
+    },
+};
+
+use iced_aw::{
+    DropDown,
+    drop_down::{self, Offset},
+};
+
+use crate::Message;
+use crate::data::TemplateWorkspace;
+
+#[derive(Clone, Debug)]
+pub enum WorkspaceMenuMessage {
+    Select(usize),
+    Dismiss,
+    Expand,
+}
+
+pub struct WorkspaceMenu {
+    /// Choices index into [crate::AppState::workspaces].
+    pub choices: Vec<usize>,
+    pub expanded: bool,
+}
+
+impl WorkspaceMenu {
+    pub fn update(&mut self, message: &WorkspaceMenuMessage) {
+        match message {
+            WorkspaceMenuMessage::Select(_) => {
+                self.expanded = false;
+            }
+            WorkspaceMenuMessage::Dismiss => self.expanded = false,
+            WorkspaceMenuMessage::Expand => self.expanded = !self.expanded,
+        }
+    }
+
+    pub fn view<'a>(
+        &'a self,
+        workspaces: &'a [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 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,
+                    )))
+                    .width(Length::Fill)
+                    .into()
+            }));
+
+        let overlay = scrollable(options.spacing(0.5));
+
+        DropDown::new(underlay, overlay, self.expanded)
+            .width(600)
+            .on_dismiss(Message::WorkspaceMenu(WorkspaceMenuMessage::Dismiss))
+            .alignment(drop_down::Alignment::BottomEnd)
+            .offset(Offset::new(-100., 40.))
+            .into()
+    }
+}

+ 66 - 0
src/model.rs

@@ -0,0 +1,66 @@
+use serde::{Deserialize, Serialize};
+use sqlx::prelude::Type;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Workspace {
+    pub id: i64,
+    pub name: String,
+}
+
+#[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, Serialize, Deserialize)]
+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, Serialize, Deserialize)]
+pub struct RequestHeader {
+    pub name: String,
+    pub value: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, Type)]
+#[sqlx(type_name = "INTEGER")]
+pub enum WorkspaceEntryType {
+    Request = 0,
+    Collection = 1,
+}
+
+impl From<i64> for WorkspaceEntryType {
+    fn from(value: i64) -> Self {
+        match value {
+            0 => Self::Request,
+            1 => Self::Collection,
+            _ => panic!("unrecognized entry type: {value}"),
+        }
+    }
+}