ソースを参照

Add var parser and envs

biblius 4 週間 前
コミット
e23f923c87

+ 3 - 2
src-tauri/migrations/20250922150745_init.up.sql

@@ -15,10 +15,11 @@ CREATE TABLE workspace_env_variables (
     workspace_id INTEGER NOT NULL,
     env_id INTEGER NOT NULL,
     name TEXT NOT NULL,
-    value TEXT,
+    value TEXT NOT NULL,
     secret BOOLEAN NOT NULL,
     FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE,
-    FOREIGN KEY (env_id) REFERENCES workspace_envs (id) ON DELETE CASCADE
+    FOREIGN KEY (env_id) REFERENCES workspace_envs (id) ON DELETE CASCADE,
+    UNIQUE(env_id, name)
 );
 
 CREATE TABLE workspace_entries (

+ 76 - 1
src-tauri/src/cmd.rs

@@ -4,7 +4,10 @@ use crate::{
     db,
     request::url::{RequestUrl, RequestUrlOwned},
     state::AppState,
-    workspace::{Workspace, WorkspaceEntry, WorkspaceEntryBase, WorkspaceEntryCreate},
+    workspace::{
+        Workspace, WorkspaceEntry, WorkspaceEntryBase, WorkspaceEntryCreate, WorkspaceEnvVariable,
+        WorkspaceEnvironment,
+    },
 };
 
 #[tauri::command]
@@ -58,3 +61,75 @@ pub fn parse_url(url: String) -> Result<RequestUrlOwned, String> {
         }
     }
 }
+
+#[tauri::command]
+pub async fn list_environments(
+    state: tauri::State<'_, AppState>,
+    workspace_id: i64,
+) -> Result<Vec<WorkspaceEnvironment>, String> {
+    match db::list_environments(state.db.clone(), workspace_id).await {
+        Ok(ws) => Ok(ws),
+        Err(e) => Err(e.to_string()),
+    }
+}
+
+#[tauri::command]
+pub async fn create_env(
+    state: tauri::State<'_, AppState>,
+    workspace_id: i64,
+    name: String,
+) -> Result<WorkspaceEnvironment, String> {
+    match db::create_environment(state.db.clone(), workspace_id, name).await {
+        Ok(ws) => Ok(ws),
+        Err(e) => Err(e.to_string()),
+    }
+}
+
+#[tauri::command]
+pub async fn update_env(
+    state: tauri::State<'_, AppState>,
+    id: i64,
+    name: String,
+) -> Result<(), String> {
+    if let Err(e) = db::update_environment(state.db.clone(), id, name).await {
+        return Err(e.to_string());
+    }
+    Ok(())
+}
+
+#[tauri::command]
+pub async fn insert_env_var(
+    state: tauri::State<'_, AppState>,
+    workspace_id: i64,
+    env_id: i64,
+    name: String,
+    value: String,
+    secret: bool,
+) -> Result<WorkspaceEnvVariable, String> {
+    match db::insert_env_var(state.db.clone(), workspace_id, env_id, name, value, secret).await {
+        Ok(var) => Ok(var),
+        Err(e) => Err(e.to_string()),
+    }
+}
+
+#[tauri::command]
+pub async fn update_env_var(
+    state: tauri::State<'_, AppState>,
+    id: i64,
+    name: Option<String>,
+    value: Option<String>,
+    secret: Option<bool>,
+) -> Result<(), String> {
+    if let Err(e) = db::update_env_var(state.db.clone(), id, name, value, secret).await {
+        return Err(e.to_string());
+    }
+    Ok(())
+}
+
+#[tauri::command]
+pub async fn delete_env_var(state: tauri::State<'_, AppState>, id: i64) -> Result<(), String> {
+    if let Err(e) = db::delete_env_var(state.db.clone(), id).await {
+        return Err(e.to_string());
+    }
+    Ok(())
+}

+ 155 - 27
src-tauri/src/db.rs

@@ -162,15 +162,14 @@ pub async fn create_workspace_entry(
 pub async fn get_workspace_entries(
     db: SqlitePool,
     workspace_id: i64,
-) -> Result<Vec<WorkspaceEntry>, String> {
+) -> AppResult<Vec<WorkspaceEntry>> {
     let entries = sqlx::query_as!(
         WorkspaceEntryBase,
         "SELECT id, workspace_id, parent_id, name, type FROM workspace_entries WHERE workspace_id = ? ORDER BY type DESC",
         workspace_id,
     )
     .fetch_all(&db)
-    .await
-    .map_err(|e| e.to_string())?;
+    .await?;
 
     let mut request_params: HashMap<i64, RequestParams> = sqlx::query_as!(
         RequestParams,
@@ -183,8 +182,7 @@ pub async fn get_workspace_entries(
         workspace_id
     )
     .fetch_all(&db)
-    .await
-    .map_err(|e| e.to_string())?
+    .await?
     .into_iter()
     .map(|req| (req.id, req))
     .collect();
@@ -200,8 +198,7 @@ pub async fn get_workspace_entries(
                     entry.id
                 )
                 .fetch_all(&db)
-                .await
-                .map_err(|e| e.to_string())?;
+                .await?;
 
                 let Some(params) = request_params.remove(&entry.id) else {
                     log::warn!("request {} has no params!", entry.id);
@@ -221,36 +218,167 @@ pub async fn get_workspace_entries(
     Ok(out)
 }
 
-pub async fn get_environments(
+pub async fn list_environments(
     db: SqlitePool,
     workspace_id: i64,
-) -> Result<Vec<WorkspaceEnvironment>, String> {
-    match sqlx::query_as!(
-        WorkspaceEnvironment,
-        "SELECT id, workspace_id, name FROM workspace_envs WHERE workspace_id = $1",
+) -> AppResult<Vec<WorkspaceEnvironment>> {
+    let records = sqlx::query!(
+        r#"
+          SELECT 
+            env.workspace_id,
+            env.id AS env_id,
+            env.name AS env_name,
+            var.id AS "var_id?",
+            var.name AS "var_name?",
+            var.value AS "var_value?",
+            var.secret AS "var_secret?"
+          FROM workspace_envs env
+          LEFT JOIN workspace_env_variables var ON env.id = var.env_id
+          WHERE env.workspace_id = $1"#,
         workspace_id
     )
     .fetch_all(&db)
-    .await
-    {
-        Ok(envs) => Ok(envs),
-        Err(e) => Err(e.to_string()),
+    .await?;
+
+    let mut environments: HashMap<i64, WorkspaceEnvironment> = HashMap::new();
+
+    for record in records {
+        if let Some(env) = environments.get_mut(&record.env_id) {
+            if record.var_id.is_some() {
+                env.variables.push(WorkspaceEnvVariable {
+                    id: record.var_id.unwrap(),
+                    workspace_id,
+                    env_id: record.env_id,
+                    name: record.var_name.unwrap(),
+                    value: record.var_value.unwrap(),
+                    secret: record.var_secret.unwrap(),
+                })
+            }
+        } else {
+            let mut env = WorkspaceEnvironment {
+                id: record.env_id,
+                name: record.env_name,
+                workspace_id,
+                variables: vec![],
+            };
+            if record.var_id.is_some() {
+                env.variables.push(WorkspaceEnvVariable {
+                    id: record.var_id.unwrap(),
+                    workspace_id,
+                    env_id: record.env_id,
+                    name: record.var_name.unwrap(),
+                    value: record.var_value.unwrap(),
+                    secret: record.var_secret.unwrap(),
+                })
+            }
+            environments.insert(record.env_id, env);
+        }
     }
+
+    Ok(environments.into_values().collect())
+}
+
+pub async fn create_environment(
+    db: SqlitePool,
+    workspace_id: i64,
+    name: String,
+) -> AppResult<WorkspaceEnvironment> {
+    let row = sqlx::query!(
+        r#"
+        INSERT INTO workspace_envs (workspace_id, name)
+        VALUES (?, ?)
+        RETURNING id, workspace_id, name
+        "#,
+        workspace_id,
+        name
+    )
+    .fetch_one(&db)
+    .await?;
+
+    Ok(WorkspaceEnvironment {
+        id: row.id,
+        workspace_id: row.workspace_id,
+        name: row.name,
+        variables: vec![],
+    })
+}
+
+pub async fn update_environment(db: SqlitePool, env_id: i64, name: String) -> AppResult<()> {
+    sqlx::query!(
+        r#"
+        UPDATE workspace_envs SET name = ? WHERE id = ?
+        "#,
+        name,
+        env_id,
+    )
+    .execute(&db)
+    .await?;
+
+    Ok(())
 }
 
-pub async fn get_workspace_env_variables(
+pub async fn insert_env_var(
     db: SqlitePool,
+    workspace_id: i64,
     env_id: i64,
-) -> Result<Vec<WorkspaceEnvVariable>, String> {
-    match sqlx::query_as!(
+    name: String,
+    value: String,
+    secret: bool,
+) -> AppResult<WorkspaceEnvVariable> {
+    Ok(sqlx::query_as!(
         WorkspaceEnvVariable,
-        "SELECT id, env_id, workspace_id, name, value, secret FROM workspace_env_variables WHERE env_id = $1",
-        env_id
+        r#"
+        INSERT INTO workspace_env_variables (workspace_id, env_id, name, value, secret)
+        VALUES (?, ?, ?, ?, ?)
+        RETURNING id, workspace_id, env_id, name, value, secret
+        "#,
+        workspace_id,
+        env_id,
+        name,
+        value,
+        secret,
     )
-    .fetch_all(&db)
-    .await
-    {
-        Ok(envs) => Ok(envs),
-        Err(e) => Err(e.to_string()),
-    }
+    .fetch_one(&db)
+    .await?)
+}
+
+pub async fn update_env_var(
+    db: SqlitePool,
+    id: i64,
+    name: Option<String>,
+    value: Option<String>,
+    secret: Option<bool>,
+) -> AppResult<()> {
+    sqlx::query_as!(
+        WorkspaceEnvVariable,
+        r#"
+        UPDATE workspace_env_variables
+        SET
+            name   = COALESCE(?, name),
+            value  = COALESCE(?, value),
+            secret = COALESCE(?, secret)
+        WHERE id = ?
+        "#,
+        name,
+        value,
+        secret,
+        id,
+    )
+    .execute(&db)
+    .await?;
+    Ok(())
+}
+
+pub async fn delete_env_var(db: SqlitePool, id: i64) -> AppResult<()> {
+    sqlx::query_as!(
+        WorkspaceEnvVariable,
+        r#"
+        DELETE FROM workspace_env_variables
+        WHERE id = ?
+        "#,
+        id,
+    )
+    .execute(&db)
+    .await?;
+    Ok(())
 }

+ 8 - 1
src-tauri/src/lib.rs

@@ -10,6 +10,7 @@ mod db;
 mod error;
 mod request;
 mod state;
+mod var;
 mod workspace;
 
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -34,7 +35,13 @@ pub fn run() {
             cmd::create_workspace,
             cmd::get_workspace_entries,
             cmd::create_workspace_entry,
-            cmd::parse_url
+            cmd::parse_url,
+            cmd::list_environments,
+            cmd::create_env,
+            cmd::update_env,
+            cmd::insert_env_var,
+            cmd::update_env_var,
+            cmd::delete_env_var
         ])
         .run(tauri::generate_context!())
         .expect("error while running tauri application");

+ 1 - 1
src-tauri/src/request/url.rs

@@ -2,7 +2,7 @@ use nom::{
     bytes::complete::{tag, take_until, take_until1, take_while, take_while1},
     character::complete::char,
     multi::many0,
-    sequence::{delimited, preceded, separated_pair},
+    sequence::{preceded, separated_pair},
     Parser,
 };
 use serde::Serialize;

+ 125 - 0
src-tauri/src/var.rs

@@ -0,0 +1,125 @@
+use nom::{
+    bytes::complete::{tag, take_until},
+    sequence::delimited,
+    Parser,
+};
+use serde::Serialize;
+
+#[derive(Debug, Serialize)]
+pub struct VarOwned {
+    pub pos: usize,
+    pub name: String,
+}
+
+impl<'a> From<Var<'a>> for VarOwned {
+    fn from(var: Var<'a>) -> Self {
+        VarOwned {
+            pos: var.pos,
+            name: var.name.to_owned(),
+        }
+    }
+}
+
+pub struct Var<'a> {
+    /// Position of the variable in the input, including the delimiter.
+    pub pos: usize,
+
+    /// Parse name of the variable, excluding delimiters.
+    pub name: &'a str,
+}
+
+pub fn parse_vars<'a>(
+    mut input: &'a str,
+) -> Result<Vec<Var<'a>>, nom::Err<nom::error::Error<&'a str>>> {
+    const VAR_START: &str = "{{";
+    const VAR_END: &str = "}}";
+
+    let mut var_parser = delimited(
+        tag::<_, _, nom::error::Error<_>>(VAR_START),
+        take_until("}"),
+        tag(VAR_END),
+    );
+
+    let mut vars = vec![];
+    let mut offset = 0;
+
+    loop {
+        while let Ok((rest, var)) = var_parser.parse(input) {
+            input = rest;
+            vars.push(Var {
+                pos: offset,
+                name: var,
+            });
+            offset += 4 + var.len();
+        }
+
+        let Ok((var_start, consumed)) = take_until::<_, _, nom::error::Error<_>>(VAR_START)(input)
+        else {
+            return Ok(vars);
+        };
+
+        offset += consumed.len();
+
+        input = var_start;
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use crate::var::parse_vars;
+
+    #[test]
+    fn parses_variable_start() {
+        let url = "{{BASE_URL}}/foo/bar";
+        let result = parse_vars(url).unwrap();
+        assert_eq!(0, result[0].pos);
+        assert_eq!("BASE_URL", result[0].name);
+    }
+
+    #[test]
+    fn parses_variables_start_adjacent() {
+        let url = "{{BASE_URL}}{{FOO}}/foo/bar";
+        let result = parse_vars(url).unwrap();
+        assert_eq!(0, result[0].pos);
+        assert_eq!("BASE_URL", result[0].name);
+        assert_eq!(12, result[1].pos);
+        assert_eq!("FOO", result[1].name);
+    }
+
+    #[test]
+    fn parses_variables_start_apart() {
+        let url = "{{BASE_URL}}/api/{{FOO}}/foo/bar";
+        let result = parse_vars(url).unwrap();
+        assert_eq!(0, result[0].pos);
+        assert_eq!("BASE_URL", result[0].name);
+        assert_eq!("{{BASE_URL}}/api/".len(), result[1].pos);
+        assert_eq!("FOO", result[1].name);
+    }
+
+    #[test]
+    fn parses_variables_apart() {
+        let url = "https://{{HOST}}:{{PORT}}/api/{{FOO}}/foo/bar";
+        let result = parse_vars(url).unwrap();
+
+        assert_eq!("https://".len(), result[0].pos);
+        assert_eq!("HOST", result[0].name);
+
+        assert_eq!("https://{{HOST}}:".len(), result[1].pos);
+        assert_eq!("PORT", result[1].name);
+
+        assert_eq!("https://{{HOST}}:{{PORT}}/api/".len(), result[2].pos);
+        assert_eq!("FOO", result[2].name);
+    }
+
+    #[test]
+    fn skips_single() {
+        let url = "https://{HOST}:{{PORT}}/api/{{FOO}}/foo/bar";
+        let result = parse_vars(url).unwrap();
+
+        assert_eq!("https://{HOST}:".len(), result[0].pos);
+        assert_eq!("PORT", result[0].name);
+
+        assert_eq!("https://{HOST}:{{PORT}}/api/".len(), result[1].pos);
+        assert_eq!("FOO", result[1].name);
+    }
+}

+ 10 - 23
src-tauri/src/workspace.rs

@@ -9,13 +9,6 @@ pub struct Workspace {
     pub name: String,
 }
 
-#[derive(Debug, Clone)]
-pub struct WorkspaceEnvironment {
-    pub id: i64,
-    pub workspace_id: i64,
-    pub name: String,
-}
-
 #[derive(Debug, Clone, Serialize)]
 #[serde(tag = "type", content = "data")]
 pub enum WorkspaceEntry {
@@ -31,20 +24,6 @@ impl WorkspaceEntry {
     pub fn new_col(col: WorkspaceEntryBase) -> Self {
         Self::Collection(col)
     }
-
-    pub fn id(&self) -> i64 {
-        match self {
-            WorkspaceEntry::Collection(c) => c.id,
-            WorkspaceEntry::Request(r) => r.entry.id,
-        }
-    }
-
-    pub fn parent_id(&self) -> Option<i64> {
-        match self {
-            WorkspaceEntry::Collection(c) => c.parent_id,
-            WorkspaceEntry::Request(r) => r.entry.parent_id,
-        }
-    }
 }
 
 /// Database model representation
@@ -57,13 +36,21 @@ pub struct WorkspaceEntryBase {
     pub r#type: WorkspaceEntryType,
 }
 
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, Serialize)]
+pub struct WorkspaceEnvironment {
+    pub id: i64,
+    pub name: String,
+    pub workspace_id: i64,
+    pub variables: Vec<WorkspaceEnvVariable>,
+}
+
+#[derive(Debug, Clone, Serialize)]
 pub struct WorkspaceEnvVariable {
     pub id: i64,
     pub env_id: i64,
     pub workspace_id: i64,
     pub name: String,
-    pub value: Option<String>,
+    pub value: String,
     pub secret: bool,
 }
 

+ 66 - 0
src/lib/components/Editable.svelte

@@ -0,0 +1,66 @@
+<script lang="ts">
+  import { Input } from "$lib/components/ui/input";
+  import { Button } from "$lib/components/ui/button";
+  import { Check, X } from "@lucide/svelte";
+  import { state as _state } from "$lib/state.svelte";
+  import { tick } from "svelte";
+
+  let { value = $bindable(), onSave } = $props();
+
+  let editing = $state(false);
+  let inputRef: HTMLInputElement;
+
+  let lastValue = "";
+
+  // Start editing
+  function startEdit() {
+    lastValue = value;
+    editing = true;
+
+    // Focus input after next tick
+    tick().then(() => {
+      console.log("inputref", inputRef);
+      inputRef!!.focus();
+      inputRef!!.select();
+    });
+  }
+
+  // Save changes
+  function save() {
+    if (value.trim()) {
+      value = value.trim();
+      onSave();
+    }
+    editing = false;
+  }
+
+  // Cancel editing
+  function cancel() {
+    value = lastValue;
+    editing = false;
+  }
+</script>
+
+<div class="w-fit flex items-center">
+  {#if editing}
+    <input
+      bind:this={inputRef}
+      bind:value
+      onkeydown={(e) => {
+        if (e.key === "Enter") save();
+        if (e.key === "Escape") cancel();
+      }}
+      class="w-fit"
+    />
+    <Button variant="ghost" size="icon" onclick={save}>
+      <Check class="w-4 h-4" />
+    </Button>
+    <Button variant="ghost" size="icon" onclick={cancel}>
+      <X class="w-4 h-4" />
+    </Button>
+  {:else}
+    <h2 class="text-lg font-semibold cursor-pointer" ondblclick={startEdit}>
+      {value}
+    </h2>
+  {/if}
+</div>

+ 125 - 0
src/lib/components/Environment.svelte

@@ -0,0 +1,125 @@
+<script lang="ts">
+  import {
+    state as _state,
+    createEnvironment,
+    insertEnvVariable,
+    selectEnvironment,
+    updateEnvironment,
+    updateEnvVariable,
+  } from "$lib/state.svelte";
+  import * as Select from "$lib/components/ui/select";
+  import { Input } from "$lib/components/ui/input";
+  import { Label } from "$lib/components/ui/label";
+  import Button from "./ui/button/button.svelte";
+  import Editable from "./Editable.svelte";
+  import type { EnvVariable } from "$lib/types";
+
+  let placeholderKey = $state("");
+  let placeholderVal = $state("");
+
+  async function handlePlaceholder() {
+    if (placeholderKey && placeholderVal) {
+      try {
+        await insertEnvVariable(
+          _state.workspace!!.id,
+          _state.environment!!.id,
+          placeholderKey,
+          placeholderVal,
+          false,
+        );
+        placeholderKey = "";
+        placeholderVal = "";
+      } catch (e) {
+        console.error("cannot insert env var", e);
+      }
+    }
+  }
+
+  async function handleUpdate(v: EnvVariable) {
+    updateEnvVariable(v);
+  }
+</script>
+
+<main class="w-full space-y-6 p-4">
+  <!-- Environment selector -->
+  <div class="flex items-center gap-4">
+    <Label>Environment</Label>
+
+    <Select.Root type="single" value={_state.environment?.id.toString() || "-"}>
+      <Select.Trigger class="w-64">
+        {_state.environment?.name || "-"}
+      </Select.Trigger>
+
+      <Select.Content>
+        <Select.Item value={"-"} onclick={() => selectEnvironment(null)}>
+          -
+        </Select.Item>
+        {#each _state.environments as env}
+          <Select.Item
+            value={env.id.toString()}
+            onclick={() => selectEnvironment(env.id)}
+          >
+            {env.name}
+          </Select.Item>
+        {/each}
+      </Select.Content>
+    </Select.Root>
+
+    <Button
+      class="w-8 h-8"
+      onclick={() => {
+        createEnvironment(_state.workspace!!.id, "New environment");
+      }}>+</Button
+    >
+  </div>
+
+  {#if _state.environment}
+    <!-- Environment name -->
+    <Editable
+      bind:value={_state.environment.name}
+      onSave={() => {
+        updateEnvironment();
+      }}
+    />
+
+    <!-- Variables -->
+    <div class="space-y-3">
+      {#each _state.environment.variables as v}
+        <div class="grid grid-cols-2 gap-3">
+          <Input
+            class="font-mono"
+            bind:value={v.name}
+            oninput={() => handleUpdate(v)}
+          />
+          <Input
+            class="font-mono"
+            bind:value={v.value}
+            oninput={() => handleUpdate(v)}
+          />
+        </div>
+      {/each}
+    </div>
+
+    <p class="w-full border-b">Add</p>
+    <div class="grid grid-cols-2 gap-3">
+      <Input
+        class="font-mono"
+        bind:value={placeholderKey}
+        onkeypress={(e) => {
+          if (e.key === "Enter") {
+            handlePlaceholder();
+          }
+        }}
+      />
+      <Input
+        class="font-mono"
+        bind:value={placeholderVal}
+        onkeypress={(e) => {
+          if (e.key === "Enter") {
+            handlePlaceholder();
+          }
+        }}
+      />
+    </div>
+  {/if}
+</main>

+ 3 - 2
src/lib/components/Sidebar.svelte

@@ -18,6 +18,7 @@
   import SidebarEntry from "./SidebarEntry.svelte";
   import SidebarActionButton from "./SidebarActionButton.svelte";
   import { getSetting } from "$lib/settings.svelte";
+  import * as Accordion from "./ui/accordion/index";
 
   let workspaces: Workspace[] = $state([]);
 
@@ -98,9 +99,9 @@
     </Sidebar.Menu>
   </Sidebar.Header>
   <Sidebar.Content>
-    <!-- Workspace entries -->
-
     {#if _state.workspace}
+      <!-- Workspace entries -->
+
       <Sidebar.Group>
         <Sidebar.GroupContent>
           <Sidebar.Menu>

+ 14 - 11
src/lib/components/WorkspaceEntry.svelte

@@ -30,10 +30,16 @@
     return parents.reverse();
   });
 
-  let urlTemplate: RequestUrl | null = $derived(await parseUrl());
+  let urlTemplate: RequestUrl | null = $derived(
+    _state.entry.type === "Request" ? await parseUrl() : null,
+  );
   let urlDynPaths = {};
 
-  async function parseUrl() {
+  async function parseUrl(): Promise<RequestUrl | null> {
+    if (!_state.entry?.url) {
+      return null;
+    }
+
     try {
       const url = await invoke<RequestUrl>("parse_url", {
         url: _state.entry.url,
@@ -43,15 +49,13 @@
           urlDynPaths[seg.value] = "";
         }
       }
-      console.log(url);
       return url;
     } catch (e) {
-      console.error(e);
       return null;
     }
   }
 
-  function constructUrl(): string {
+  function constructUrl() {
     if (!urlTemplate) {
       return "";
     }
@@ -59,19 +63,18 @@
     const path = urlTemplate.path
       .map((s) => (s.type === "Dynamic" ? `:${s.value}` : s.value))
       .join("/");
+
     const query = urlTemplate.query_params.length
       ? "?" + urlTemplate.query_params.map((p) => `${p[0]}=${p[1]}`).join("&")
       : "";
 
-    _state.entry.url = `${urlTemplate.scheme}://${urlTemplate.host}/${path}${query}`;
-    parseUrl(_state.entry.url);
-    console.log(_state.entry.url);
+    _state.entry!!.url = `${urlTemplate.scheme}://${urlTemplate.host}/${path}${query}`;
+
+    parseUrl();
   }
 
   onMount(() => {
-    if (_state.entry?.type === "Request") {
-      parseUrl(_state.entry.url);
-    }
+    parseUrl();
   });
 </script>
 

+ 7 - 0
src/lib/components/ui/label/index.ts

@@ -0,0 +1,7 @@
+import Root from "./label.svelte";
+
+export {
+	Root,
+	//
+	Root as Label,
+};

+ 20 - 0
src/lib/components/ui/label/label.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import { Label as LabelPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: LabelPrimitive.RootProps = $props();
+</script>
+
+<LabelPrimitive.Root
+	bind:ref
+	data-slot="label"
+	class={cn(
+		"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
+		className
+	)}
+	{...restProps}
+/>

+ 127 - 20
src/lib/state.svelte.ts

@@ -4,6 +4,8 @@ import type {
   WorkspaceEntryBase,
   RequestBody,
   WorkspaceEntry,
+  WorkspaceEnvironment,
+  EnvVariable,
 } from "./types";
 
 export type WorkspaceState = {
@@ -31,6 +33,16 @@ export type WorkspaceState = {
    * All workspace entries.
    */
   indexes: Record<number, WorkspaceEntry>;
+
+  /**
+   * Currently selected workspace environments.
+   */
+  environments: WorkspaceEnvironment[];
+
+  /**
+   * Currently selected environment.
+   */
+  environment: WorkspaceEnvironment | null;
 };
 
 export const state: WorkspaceState = $state({
@@ -39,6 +51,8 @@ export const state: WorkspaceState = $state({
   roots: [],
   children: {},
   indexes: {},
+  environments: [],
+  environment: null,
 });
 
 const index = (entry: WorkspaceEntry) => {
@@ -56,10 +70,46 @@ const index = (entry: WorkspaceEntry) => {
   }
 };
 
+function reset() {
+  state.children = {};
+  state.indexes = {};
+  state.roots = [];
+  state.entry = null;
+  state.environment = null;
+  state.environments = [];
+}
+
+export function selectEnvironment(id: number | null) {
+  if (id === null) {
+    state.environment = null;
+    return;
+  }
+  console.debug("selecting environment:", state.environments[id]);
+  state.environment = state.environments.find((e) => e.id === id) ?? null;
+}
+
 export function selectWorkspace(ws: Workspace) {
+  console.debug("selecting workspace:", ws.name);
   state.workspace = ws;
 }
 
+export function selectEntry(id: number) {
+  console.log("selecting entry:", id);
+  state.entry = state.indexes[id];
+  if (state.entry.parent_id !== null) {
+    let parent = state.indexes[state.entry.parent_id];
+    while (parent) {
+      parent.open = true;
+      if (parent.parent_id === null) {
+        break;
+      }
+      parent = state.indexes[parent.parent_id];
+    }
+  }
+}
+
+// COMMANDS
+
 export async function createWorkspace(name: string): Promise<Workspace> {
   return invoke<Workspace>("create_workspace", { name });
 }
@@ -69,10 +119,7 @@ export async function listWorkspaces(): Promise<Workspace[]> {
 }
 
 export async function loadWorkspace(ws: Workspace) {
-  state.children = {};
-  state.indexes = {};
-  state.roots = [];
-  state.entry = null;
+  reset();
 
   state.workspace = ws;
 
@@ -96,9 +143,11 @@ export async function loadWorkspace(ws: Workspace) {
       index(entry.data);
     }
   }
+
+  await loadEnvironments(state.workspace.id);
 }
 
-export function createRequest(parent_id?: number) {
+export function createRequest(parentId?: number) {
   if (state.workspace == null) {
     console.warn("create workspace request called with no active workspace");
     return;
@@ -108,7 +157,7 @@ export function createRequest(parent_id?: number) {
     Request: {
       name: "",
       workspace_id: state.workspace.id,
-      parent_id,
+      parent_id: parentId,
       method: "GET",
       url: "",
     },
@@ -128,7 +177,7 @@ export function createRequest(parent_id?: number) {
   });
 }
 
-export function createCollection(parent_id?: number) {
+export function createCollection(parentId?: number) {
   if (state.workspace == null) {
     console.warn("create workspace request called with no active workspace");
     return;
@@ -138,7 +187,7 @@ export function createCollection(parent_id?: number) {
     Collection: {
       name: "",
       workspace_id: state.workspace.id,
-      parent_id,
+      parent_id: parentId,
     },
   };
   invoke<WorkspaceEntryBase>("create_workspace_entry", {
@@ -149,19 +198,77 @@ export function createCollection(parent_id?: number) {
   });
 }
 
-export function selectEntry(id: number) {
-  state.entry = state.indexes[id];
-  console.log("entry selected:", id);
-  if (state.entry.parent_id !== null) {
-    let parent = state.indexes[state.entry.parent_id];
-    while (parent) {
-      parent.open = true;
-      if (parent.parent_id === null) {
-        break;
-      }
-      parent = state.indexes[parent.parent_id];
-    }
+export async function loadEnvironments(workspaceId: number) {
+  state.environments = await invoke<WorkspaceEnvironment[]>(
+    "list_environments",
+    { workspaceId },
+  );
+}
+
+export async function createEnvironment(workspaceId: number, name: string) {
+  console.debug("creating environment in", workspaceId);
+  const env = await invoke<WorkspaceEnvironment>("create_env", {
+    workspaceId,
+    name,
+  });
+  state.environment = env;
+  state.environments.push(state.environment);
+}
+
+export async function updateEnvironment() {
+  if (!state.environment) {
+    console.warn("attempted to persist null env");
+    return;
   }
+
+  console.debug("updating environment", state.environment);
+
+  await invoke("update_env", {
+    id: state.environment.id,
+    name: state.environment.name,
+  });
+}
+
+export async function insertEnvVariable(
+  workspaceId: number,
+  envId: number,
+  name: string = "",
+  value: string = "",
+  secret: boolean = false,
+) {
+  const v = await invoke<EnvVariable>("insert_env_var", {
+    workspaceId,
+    envId,
+    name,
+    value,
+    secret,
+  });
+
+  state.environment?.variables.push(v);
+}
+
+export async function updateEnvVariable(v: EnvVariable) {
+  if (v.name.length === 0 && v.value.length === 0) {
+    console.debug("deleting var:", v);
+
+    return deleteEnvVariable(v.id);
+  }
+
+  console.debug("updating var:", v);
+
+  return invoke("update_env_var", {
+    id: v.id,
+    name: v.name,
+    value: v.value,
+    secret: v.secret,
+  });
+}
+
+export async function deleteEnvVariable(id: number) {
+  await invoke("delete_env_var", { id });
+  state.environment!!.variables = state.environment!!.variables.filter(
+    (v) => v.id !== id,
+  );
 }
 
 type WorkspaceEntryResponse =

+ 14 - 0
src/lib/types.ts

@@ -56,3 +56,17 @@ export type RequestBody = {
   content: string;
   ty: string;
 };
+
+export type WorkspaceEnvironment = {
+  id: number;
+  name: string;
+  workspace_id: number;
+  variables: EnvVariable[];
+};
+
+export type EnvVariable = {
+  id: number;
+  name: string;
+  value: string;
+  secret: boolean;
+};

+ 23 - 1
src/routes/+page.svelte

@@ -5,14 +5,25 @@
   import { Button } from "$lib/components/ui/button";
   import { SunIcon, MoonIcon } from "@lucide/svelte";
   import WorkspaceEntry from "$lib/components/WorkspaceEntry.svelte";
+  import { state as _state } from "$lib/state.svelte";
+  import { SlidersHorizontal } from "@lucide/svelte";
+  import Environment from "$lib/components/Environment.svelte";
 
   let { children } = $props();
+
+  let displayEnvs = $state(false);
 </script>
 
 <Sidebar.Provider>
   <AppSidebar />
 
-  <WorkspaceEntry />
+  {#if displayEnvs}
+    <Environment />
+  {:else if _state.entry}
+    <WorkspaceEntry />
+  {:else}
+    <main class="w-full h-full p-4 space-y-4"></main>
+  {/if}
 
   {@render children?.()}
 
@@ -30,6 +41,17 @@
             <span class="sr-only">Toggle theme</span>
           </Button>
         </Sidebar.MenuItem>
+
+        <Sidebar.MenuItem class="pt-2">
+          <Button
+            onclick={() => (displayEnvs = !displayEnvs)}
+            variant="ghost"
+            size="icon-sm"
+          >
+            <SlidersHorizontal class="" />
+            <span class="sr-only">Display environment</span>
+          </Button>
+        </Sidebar.MenuItem>
       </Sidebar.Menu>
     </Sidebar.Root>
   </Sidebar.Provider>