Ver Fonte

improve query param management

biblius há 2 semanas atrás
pai
commit
7b3535c6be

+ 2 - 1
src-tauri/migrations/20250922150745_init.down.sql

@@ -1,7 +1,8 @@
 DROP TABLE request_headers;
 DROP TABLE request_bodies;
-DROP TABLE request_params;
+DROP TABLE request_query_params;
 DROP TABLE request_path_params;
+DROP TABLE request_params;
 DROP TABLE workspace_entries;
 DROP TABLE workspace_env_variables;
 DROP TABLE workspace_envs;

+ 11 - 0
src-tauri/migrations/20250922150745_init.up.sql

@@ -63,6 +63,17 @@ CREATE TABLE request_path_params (
     FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE
 );
 
+CREATE TABLE request_query_params (
+    id INTEGER PRIMARY KEY NOT NULL,
+    position INTEGER NOT NULL,
+    request_id INTEGER NOT NULL,
+    key TEXT NOT NULL,
+    value TEXT NOT NULL,
+    enabled BOOLEAN NOT NULL DEFAULT TRUE,
+    UNIQUE(position, request_id),
+    FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE
+);
+
 CREATE TABLE request_bodies (
     id INTEGER PRIMARY KEY NOT NULL,
     request_id UNIQUE NOT NULL,

+ 19 - 0
src-tauri/seed/init.sql

@@ -5,6 +5,9 @@ INSERT INTO workspaces(id, name) VALUES (0, 'My workspace');
 
 INSERT INTO workspace_envs(id, workspace_id, name) VALUES (0, 0, 'My env');
 
+
+-- JSON responses and simple nesting
+
 INSERT INTO 
   workspace_env_variables(id, workspace_id, env_id, name, value, secret)
   VALUES(0, 0, 0, 'BASE_URL', 'https://jsonplaceholder.typicode.com', false);
@@ -31,3 +34,19 @@ INSERT INTO request_path_params(position, request_id, name, value)
     (18, 2, 'ID', '1');
 
 INSERT INTO request_headers(request_id, name, value) VALUES(1, 'accept', '*/*');
+
+-- Hardcore nesting
+
+INSERT INTO 
+  workspace_entries(id, workspace_id, parent_id, name, type)
+  VALUES
+    (3, 0, 0, 'Beginning of the nesting', 1),
+    (4, 0, 3, 'Continuation of the nesting', 1),
+    (5, 0, 4, 'Beginning of hardcore nesting', 1),
+    (6, 0, 5, 'This one is really nested and has a long name', 1),
+    (7, 0, 6, 'The finally nested requested request', 0);
+
+INSERT INTO 
+  request_params(workspace_id, request_id, method, url)
+  VALUES 
+    (0, 7, 'GET', 'https://codemirror.net/examples/readonly/');

+ 9 - 2
src-tauri/src/cmd.rs

@@ -3,8 +3,8 @@ use crate::{
     db::{self, Update},
     request::{
         url::{RequestUrl, RequestUrlOwned, Segment, UrlError},
-        EntryRequestBody, HttpRequestParameters, HttpResponse, RequestBody, RequestHeader,
-        RequestHeaderInsert, RequestHeaderUpdate, RequestPathParam, RequestPathUpdate,
+        EntryRequestBody, HttpRequestParameters, RequestBody, RequestHeader, RequestHeaderInsert,
+        RequestHeaderUpdate, RequestPathParam, RequestPathUpdate,
     },
     state::{AppState, ResponseResult},
     var::{expand_vars, parse_vars},
@@ -179,6 +179,8 @@ pub async fn expand_url(
 ///
 /// * `entry_id`: The request entry ID  whose URL will be updated.
 /// * `env_id`: The environment to use for expanding variables.
+/// * `use_path_params: Set to true when the user edits the path param from the `Parameters`
+/// section. Set to false when the user edits the path key directly in the URL.
 /// * `url`: The URL string which will be populated from the env and parsed with [RequestUrl].
 /// * `path_params`: List of dynamic path params to persist for the URL.
 #[tauri::command]
@@ -204,6 +206,7 @@ pub async fn update_url(
                     .iter()
                     .find(|pp| pp.position as usize == *position || &pp.name == seg)
                 else {
+                    // A new path param is being added
                     update.push(RequestPathUpdate {
                         position: *position,
                         name: seg.to_string(),
@@ -212,6 +215,7 @@ pub async fn update_url(
                     continue;
                 };
 
+                // The path was edited from the parameters section
                 if use_path_params {
                     update.push(RequestPathUpdate {
                         position: *position,
@@ -220,6 +224,7 @@ pub async fn update_url(
                     });
                     subs.push(Segment::Dynamic(&path_param.name, *position));
                 } else {
+                    // The path was edited in the URL section
                     update.push(RequestPathUpdate {
                         position: *position,
                         name: seg.to_string(),
@@ -236,6 +241,8 @@ pub async fn update_url(
                 state.db.clone(),
                 entry_id,
                 WorkspaceEntryUpdate::Request {
+                    // TODO:
+                    query_params: None,
                     path_params: Some(update),
                     url: Some(url_parsed.to_string()),
                     base: WorkspaceEntryUpdateBase::default(),

+ 21 - 3
src-tauri/src/db.rs

@@ -3,7 +3,7 @@ use crate::{
     error::AppError,
     request::{
         EntryRequestBody, RequestBody, RequestHeader, RequestHeaderInsert, RequestHeaderUpdate,
-        RequestParams, RequestPathParam, WorkspaceRequest,
+        RequestParams, RequestPathParam, RequestQueryParam, WorkspaceRequest,
     },
     workspace::{
         Workspace, WorkspaceEntry, WorkspaceEntryBase, WorkspaceEntryCreate, WorkspaceEntryType,
@@ -268,6 +268,7 @@ pub async fn update_workspace_entry(
             method,
             url,
             path_params,
+            query_params,
         } => {
             let mut tx = db.begin().await?;
 
@@ -439,11 +440,20 @@ pub async fn get_workspace_request(db: SqlitePool, id: i64) -> AppResult<Workspa
     .fetch_all(&db)
     .await?;
 
-    Ok(WorkspaceRequest::from_params_and_headers(
+    let query_params = sqlx::query_as!(
+        RequestQueryParam,
+        "SELECT position, key, value, enabled FROM request_query_params WHERE request_id = ?",
+        entry.id
+    )
+    .fetch_all(&db)
+    .await?;
+
+    Ok(WorkspaceRequest::from_entry(
         entry,
         params,
         headers,
         path_params,
+        query_params,
     ))
 }
 
@@ -501,8 +511,16 @@ pub async fn get_workspace_entry(db: SqlitePool, id: i64) -> AppResult<Workspace
             .fetch_all(&db)
             .await?;
 
+            let query_params = sqlx::query_as!(
+                RequestQueryParam,
+                "SELECT position, key, value, enabled FROM request_query_params WHERE request_id = ?",
+                entry.id
+            )
+            .fetch_all(&db)
+            .await?;
+
             let req =
-                WorkspaceRequest::from_params_and_headers(entry, params, headers, path_params);
+                WorkspaceRequest::from_entry(entry, params, headers, path_params, query_params);
 
             Ok(WorkspaceEntry::new_req(req))
         }

+ 23 - 13
src-tauri/src/request.rs

@@ -135,25 +135,18 @@ pub struct WorkspaceRequest {
 
     /// URL path keys => values.
     pub path_params: Vec<RequestPathParam>,
+
+    /// URL query params.
+    pub query_params: Vec<RequestQueryParam>,
 }
 
 impl WorkspaceRequest {
-    pub fn new(entry: WorkspaceEntryBase, method: String, url: String) -> Self {
-        Self {
-            entry,
-            method,
-            url,
-            body: None,
-            headers: vec![],
-            path_params: vec![],
-        }
-    }
-
-    pub fn from_params_and_headers(
+    pub fn from_entry(
         entry: WorkspaceEntryBase,
         params: RequestParams,
         headers: Vec<RequestHeader>,
         path_params: Vec<RequestPathParam>,
+        query_params: Vec<RequestQueryParam>,
     ) -> Self {
         let body = match (params.body_id, params.body, params.content_type) {
             (Some(id), Some(body), Some(content_type)) => Some(EntryRequestBody {
@@ -171,6 +164,7 @@ impl WorkspaceRequest {
             body,
             headers,
             path_params,
+            query_params,
         }
     }
 
@@ -180,7 +174,7 @@ impl WorkspaceRequest {
                 crate::auth::TokenPlacement::Query => {
                     let mut url = RequestUrl::parse(&self.url)?;
 
-                    url.query_params.push((&token.name, &token.value));
+                    url.add_qp_clear_trail(&token.name, &token.value);
 
                     self.url = url.to_string();
                 }
@@ -366,6 +360,14 @@ pub struct RequestPathParam {
     pub value: String,
 }
 
+#[derive(Debug, Deserialize, Serialize)]
+pub struct RequestQueryParam {
+    pub position: i64,
+    pub key: String,
+    pub value: String,
+    pub enabled: bool,
+}
+
 #[derive(Debug, Deserialize)]
 pub struct RequestPathUpdate {
     pub position: usize,
@@ -373,6 +375,14 @@ pub struct RequestPathUpdate {
     pub value: Option<String>,
 }
 
+#[derive(Debug, Deserialize, Serialize)]
+pub struct RequestQueryUpdate {
+    pub position: usize,
+    pub key: String,
+    pub value: Option<String>,
+    pub enabled: Option<bool>,
+}
+
 #[derive(Debug, Serialize, FromRow)]
 pub struct RequestHeader {
     pub id: i64,

+ 440 - 133
src-tauri/src/request/url.rs

@@ -1,9 +1,8 @@
 use nom::{
     bytes::complete::{tag, take_while},
     character::complete::char,
-    combinator::opt,
-    multi::many0,
-    sequence::{preceded, separated_pair, terminated},
+    multi::{many0, separated_list0},
+    sequence::{preceded, separated_pair},
     Parser,
 };
 use serde::{Deserialize, Serialize};
@@ -16,8 +15,8 @@ pub struct RequestUrlOwned {
     // pub scheme: String,
     // pub host: String,
     pub path: Vec<SegmentOwned>,
-    pub query_params: Vec<(String, String)>,
-    pub has_query: bool,
+    pub query_params: Vec<QueryParamOwned>,
+    pub trail: String,
 }
 
 #[derive(Debug, Serialize, Deserialize)]
@@ -27,15 +26,20 @@ pub enum SegmentOwned {
     Dynamic(String, usize),
 }
 
+#[derive(Debug, Serialize, Deserialize)]
+pub struct QueryParamOwned {
+    pub key: String,
+    pub value: String,
+    pub pos: usize,
+}
+
 /// A fully deconstructed URL from a workspace request.
 /// Used as an intermediate step for populating the final URL with variables.
 #[derive(Debug)]
 pub struct RequestUrl<'a> {
-    /// The URL scheme, e.g. `http`.
-    // pub scheme: &'a str,
-
-    /// The URL host, includes the port if specified.
-    // pub host: &'a str,
+    /// The URL pre-string. We do not parse schemes nor
+    /// hosts since those are ususally kept in variables, and we
+    /// do not need it that much for reqwest.
     pub pre: &'a str,
 
     /// The URL path segments.
@@ -45,118 +49,91 @@ pub struct RequestUrl<'a> {
     pub path: Vec<Segment<'a>>,
 
     /// Query parameters.
-    pub query_params: Vec<(&'a str, &'a str)>,
+    pub query_params: Vec<QueryParam<'a>>,
 
-    /// Whether or not a '?' was found during parsing. If true, indicates a
-    /// '?' must be set when reconstructing.
-    pub trailing_query: bool,
-
-    /// Whether or not a trailing '&' was found during parsing. If true, indicates a
-    /// '&' must be set when reconstructing.
-    pub trailing_query_pair: bool,
+    /// URL trail that did not match any path or query parameters.
+    pub trail: &'a str,
 }
 
-type NomError<'a> = nom::Err<(&'a str, nom::error::ErrorKind)>;
-
 impl<'a> RequestUrl<'a> {
-    pub fn parse(original_input: &'a str) -> Result<Self, UrlParseError> {
-        fn parse_query(query: &str) -> Result<(Vec<(&str, &str)>, bool), NomError<'_>> {
-            if query.is_empty() {
-                return Ok((vec![], false));
-            }
-
-            // Parse query
-            // First char will always be a '?' since we parsed succesfully
-            let mut query = &query[1..];
-            let mut query_params = vec![];
-
-            let mut trailing_pair = false;
-
-            loop {
-                if query.is_empty() {
-                    break;
-                }
+    pub fn parse(input: &'a str) -> Result<Self, UrlParseError> {
+        let mut offset = 0;
 
-                let (i, params) = separated_pair(
-                    take_while(|c: char| c != '='),
-                    char('='),
-                    take_while(|c: char| c != '&'),
-                )
-                .parse(query)?;
+        // Attempt to match ://
 
-                query = i;
-                query_params.push((params.0, params.1));
+        let (path, pre) = match (
+            take_while(|c| c != ':'),
+            tag::<_, _, nom::error::Error<_>>("://"),
+        )
+            .parse(input)
+        {
+            Ok((rest, (scheme, tag))) => {
+                offset += scheme.len() + tag.len();
 
-                if let Ok((i, _)) = char::<_, nom::error::Error<_>>('&').parse(query) {
-                    trailing_pair = i.is_empty();
-                    query = i;
-                }
-            }
+                // If no path segments, host contains query params if any,
+                // otherwise path contains it.
+                let (path, host) =
+                    take_while(|c| c != '/')(rest).map_err(|e| map_nom_err(input, None, e))?;
 
-            debug_assert!(query.is_empty());
+                if path.is_empty() {
+                    let (query, query_pre) =
+                        take_while(|c| c != '?')(host).map_err(|e| map_nom_err(host, None, e))?;
 
-            Ok((query_params, trailing_pair))
-        }
+                    offset += query_pre.len();
 
-        let mut offset = 0;
+                    let (query_params, trail) = QueryParam::parse(query, offset);
 
-        // Parse the tag if it exists
+                    return Ok(RequestUrl {
+                        pre: &input[..offset],
+                        path: vec![],
+                        query_params,
+                        trail,
+                    });
+                }
 
-        let input =
-            match opt(terminated(take_while(|c| c != ':'), tag("://"))).parse(original_input) {
-                Ok((input, scheme)) => match scheme {
-                    Some(scheme) => {
-                        offset += scheme.len() + 3;
-                        input
-                    }
-                    None => input,
-                },
-                Err(e) => return Err(map_nom_err(original_input, None, e)),
-            };
+                offset += host.len();
 
-        // Parse until first /
+                (path, &input[..offset])
+            }
+            Err(_) => {
+                let (path, pre) =
+                    take_while(|c| c != '/')(input).map_err(|e| map_nom_err(input, None, e))?;
 
-        let (path, host) =
-            take_while(|c| c != '/')(input).map_err(|e| map_nom_err(input, None, e))?;
+                if path.is_empty() {
+                    let (query, pre) =
+                        take_while(|c| c != '?')(pre).map_err(|e| map_nom_err(pre, None, e))?;
 
-        // We've fully parsed the string, no path
+                    offset += pre.len();
 
-        if path.is_empty() {
-            let (query, remainder) =
-                take_while(|c| c != '?')(host).map_err(|e| map_nom_err(host, None, e))?;
+                    let (query_params, trail) = QueryParam::parse(query, offset);
 
-            offset += remainder.len();
+                    return Ok(RequestUrl {
+                        pre,
+                        path: vec![],
+                        query_params,
+                        trail,
+                    });
+                }
 
-            let (query_params, trailing_query_pair) =
-                parse_query(query).map_err(|e| map_nom_err(query, None, e))?;
+                offset += pre.len();
 
-            return Ok(RequestUrl {
-                pre: &original_input[..offset],
-                path: vec![],
-                query_params,
-                trailing_query: query == "?",
-                trailing_query_pair,
-            });
-        }
+                #[cfg(debug_assertions)]
+                debug_assert_eq!(&input[..offset], pre);
 
-        offset += host.len();
+                (path, &input[..offset])
+            }
+        };
 
         // Parse until query
 
         let (query, path) =
             take_while(|c| c != '?')(path).map_err(|e| map_nom_err(path, None, e))?;
 
-        #[cfg(debug_assertions)]
-        debug_assert_eq!(&original_input[offset..], path.to_owned() + query);
-
-        let (query_params, trailing) =
-            parse_query(query).map_err(|e| map_nom_err(query, None, e))?;
-
-        let (remainder, segments) = many0(preceded(char('/'), take_while(|c| c != '/')))
+        let (_rest, segments) = many0(preceded(char('/'), take_while(|c| c != '/')))
             .parse(path)
             .map_err(|e| map_nom_err(path, None, e))?;
 
-        let mut segment_offset = offset;
+        debug_assert!(_rest.is_empty());
 
         let mut path = vec![];
 
@@ -169,31 +146,32 @@ impl<'a> RequestUrl<'a> {
             {
                 Ok((remainder, segment)) => {
                     debug_assert_eq!("", remainder);
-                    path.push(Segment::Dynamic(segment, segment_offset));
+                    path.push(Segment::Dynamic(segment, offset));
                     // account for :
-                    segment_offset += segment.len() + 1;
+                    offset += segment.len() + 1;
                 }
                 Err(_) => {
-                    path.push(Segment::Static(segment, segment_offset));
-                    segment_offset += segment.len();
+                    path.push(Segment::Static(segment, offset));
+                    offset += segment.len();
                 }
             }
 
             // account for the parsed /
-            segment_offset += 1;
+            offset += 1;
         }
 
-        debug_assert!(remainder.is_empty());
+        let (query_params, trail) = QueryParam::parse(query, offset);
 
         Ok(RequestUrl {
-            pre: &original_input[..offset],
+            pre: pre,
             path,
             query_params,
-            trailing_query: query == "?",
-            trailing_query_pair: trailing,
+            trail,
         })
     }
 
+    /// Replaces all dynamic path segments with the value from `path_params`.
+    /// The segment value is the key to the map.
     pub fn populate_path(&mut self, path_params: HashMap<&'a str, &'a str>) {
         let mut total_displaced = 0i64;
 
@@ -228,6 +206,7 @@ impl<'a> RequestUrl<'a> {
         }
     }
 
+    /// Swap the path segment at `new`'s position with it and adjust subsequent offsets.
     pub fn swap_path_segment(&mut self, new: Segment<'a>) {
         let Some((skip, segment)) = self
             .path
@@ -255,19 +234,59 @@ impl<'a> RequestUrl<'a> {
             }
         }
     }
+
+    pub fn swap_query_param(&mut self, new: QueryParam<'a>) {
+        let Some((skip, qp)) = self
+            .query_params
+            .iter_mut()
+            .enumerate()
+            .map(|(i, qp)| (i + 1, qp))
+            .find(|(_, qp)| qp.pos == new.pos)
+        else {
+            log::warn!(
+                "Attempted to swap query param with invalid position {}",
+                new.pos
+            );
+            return;
+        };
+
+        let offset = new.total_len() as i64 - qp.total_len() as i64;
+
+        *qp = new;
+
+        for qp in self.query_params.iter_mut().skip(skip) {
+            if offset < 0 {
+                qp.pos = qp.pos - offset.abs() as usize;
+            } else {
+                qp.pos = qp.pos + offset as usize;
+            }
+        }
+    }
+
+    pub fn add_qp_clear_trail(&mut self, key: &'a str, value: &'a str) {
+        if let Some(last) = self.query_params.last() {
+            // +1 for the =, +1 for the &
+            let pos = last.key.len() + 2 + last.value.len();
+            self.query_params.push(QueryParam { key, value, pos });
+        } else {
+            // +1 for the ?
+            let mut pos = self.pre.len() + 1;
+            for path in self.path.iter() {
+                pos += path.total_len();
+            }
+            self.query_params.push(QueryParam { key, value, pos });
+        }
+        self.trail = "";
+    }
 }
 
 impl<'a> From<RequestUrl<'a>> for RequestUrlOwned {
     fn from(value: RequestUrl<'_>) -> Self {
         Self {
-            pre: value.pre.to_string(),
+            pre: value.pre.to_owned(),
             path: value.path.into_iter().map(Into::into).collect(),
-            query_params: value
-                .query_params
-                .into_iter()
-                .map(|(k, v)| (k.to_owned(), v.to_owned()))
-                .collect(),
-            has_query: value.trailing_query,
+            query_params: value.query_params.into_iter().map(Into::into).collect(),
+            trail: value.trail.to_owned(),
         }
     }
 }
@@ -280,8 +299,7 @@ impl<'a> Display for RequestUrl<'a> {
             pre,
             path,
             query_params,
-            trailing_query,
-            trailing_query_pair,
+            trail,
         } = self;
 
         let path = path.iter().fold(String::new(), |mut acc, el| {
@@ -291,33 +309,23 @@ impl<'a> Display for RequestUrl<'a> {
         });
 
         let query = if query_params.is_empty() {
-            if *trailing_query {
-                String::from("?")
-            } else {
-                String::new()
-            }
+            String::new()
         } else {
-            let mut params = query_params.iter().enumerate().fold(
-                String::from("?"),
-                |mut acc, (i, (key, val))| {
-                    acc.push_str(key);
+            query_params
+                .iter()
+                .enumerate()
+                .fold(String::from("?"), |mut acc, (i, q)| {
+                    acc.push_str(q.key);
                     acc.push('=');
-                    acc.push_str(val);
+                    acc.push_str(q.value);
                     if i < query_params.len() - 1 {
                         acc.push('&')
                     }
                     acc
-                },
-            );
-
-            if *trailing_query_pair {
-                params.push('&');
-            }
-
-            params
+                })
         };
 
-        write!(f, "{pre}{path}{query}")
+        write!(f, "{pre}{path}{query}{trail}")
     }
 }
 
@@ -343,6 +351,8 @@ impl<'a> Segment<'a> {
         }
     }
 
+    /// Return the length of the segment's value, excluding the `/` and the `:` in dynamic
+    /// segments.
     pub fn len(&self) -> usize {
         match self {
             Segment::Static(s, _) => s.len(),
@@ -350,6 +360,15 @@ impl<'a> Segment<'a> {
         }
     }
 
+    /// Return the full length of the path segment including its `/` and `:` in case of dynamic
+    /// values.
+    pub fn total_len(&self) -> usize {
+        match self {
+            Segment::Static(s, _) => s.len() + 1,
+            Segment::Dynamic(s, _) => s.len() + 2,
+        }
+    }
+
     pub fn set_position(&mut self, pos: usize) {
         match self {
             Segment::Static(_, p) => *p = pos,
@@ -376,6 +395,73 @@ impl<'a> Display for Segment<'a> {
     }
 }
 
+#[derive(Debug, PartialEq, Eq)]
+pub struct QueryParam<'a> {
+    pub key: &'a str,
+    pub value: &'a str,
+    pub pos: usize,
+}
+
+impl<'a> QueryParam<'a> {
+    fn parse(query: &'a str, mut offset: usize) -> (Vec<Self>, &'a str) {
+        if query.is_empty() {
+            return (vec![], "");
+        }
+
+        if query == "?" {
+            return (vec![], "?");
+        }
+
+        offset += 1;
+
+        let query = &query[1..];
+
+        let mut query_params = vec![];
+
+        let (rest, params) = separated_list0(
+            char('&'),
+            separated_pair(
+                take_while::<_, _, nom::error::Error<_>>(|c: char| c != '='),
+                char('='),
+                take_while(|c: char| c != '&'),
+            ),
+        )
+        .parse(query)
+        .unwrap();
+
+        for param in params {
+            query_params.push(QueryParam {
+                key: param.0,
+                value: param.1,
+                pos: offset,
+            });
+            // +1 for the &, +1 for the =
+            offset += param.0.len() + 2 + param.1.len();
+        }
+
+        (query_params, rest)
+    }
+
+    fn total_len(&self) -> usize {
+        self.key.len() + 1 + self.value.len()
+    }
+
+    #[cfg(test)]
+    fn new(key: &'a str, value: &'a str, pos: usize) -> Self {
+        Self { key, value, pos }
+    }
+}
+
+impl<'a> From<QueryParam<'a>> for QueryParamOwned {
+    fn from(value: QueryParam<'a>) -> Self {
+        Self {
+            key: value.key.to_owned(),
+            value: value.value.to_owned(),
+            pos: value.pos,
+        }
+    }
+}
+
 #[derive(Debug, Serialize)]
 #[serde(tag = "type", content = "error")]
 pub enum UrlError {
@@ -451,6 +537,8 @@ where
 mod tests {
     use std::collections::HashMap;
 
+    use crate::request::url::QueryParam;
+
     use super::{RequestUrl, Segment};
 
     #[test]
@@ -562,7 +650,34 @@ mod tests {
         assert_eq!("http://localhost:4000", url.pre);
 
         assert!(url.path.is_empty());
-        assert_eq!(vec![("foo", "bar"), ("baz", "bax")], url.query_params);
+
+        assert_eq!(
+            vec![
+                QueryParam::new("foo", "bar", "http://localhost:4000?".len()),
+                QueryParam::new("baz", "bax", "http://localhost:4000?foo=bar&".len()),
+            ],
+            url.query_params
+        );
+    }
+
+    #[test]
+    fn parse_query_params_dirty() {
+        let input = "http://localhost:4000?=bar&baz=&=";
+
+        let url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http://localhost:4000", url.pre);
+
+        assert!(url.path.is_empty());
+
+        assert_eq!(
+            vec![
+                QueryParam::new("", "bar", "http://localhost:4000?".len()),
+                QueryParam::new("baz", "", "http://localhost:4000?=bar&".len()),
+                QueryParam::new("", "", "http://localhost:4000?=bar&baz=&".len()),
+            ],
+            url.query_params
+        );
     }
 
     #[test]
@@ -581,7 +696,17 @@ mod tests {
             ],
             url.path
         );
-        assert_eq!(vec![("foo", "bar"), ("baz", "bax")], url.query_params);
+        assert_eq!(
+            vec![
+                QueryParam::new("foo", "bar", "http://localhost:4000/foo/:bar/:qux?".len()),
+                QueryParam::new(
+                    "baz",
+                    "bax",
+                    "http://localhost:4000/foo/:bar/:qux?foo=bar&".len()
+                )
+            ],
+            url.query_params
+        );
     }
 
     #[test]
@@ -601,7 +726,189 @@ mod tests {
             ],
             url.path
         );
-        assert_eq!(vec![("foo", "bar"), ("baz", "bax")], url.query_params);
+        assert_eq!(
+            vec![
+                QueryParam::new("foo", "bar", "http://localhost:4000/foo/:bar/:qux/?".len()),
+                QueryParam::new(
+                    "baz",
+                    "bax",
+                    "http://localhost:4000/foo/:bar/:qux/?foo=bar&".len()
+                )
+            ],
+            url.query_params
+        );
+    }
+
+    #[test]
+    fn parse_query_params_empty() {
+        let input = "http://localhost:4000?";
+
+        let url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http://localhost:4000", url.pre);
+        assert_eq!("?", url.trail);
+    }
+
+    #[test]
+    fn swap_query_params() {
+        let input = "http://localhost:4000?foo=bar&qux=420";
+
+        let mut url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http://localhost:4000", url.pre);
+
+        assert!(url.path.is_empty());
+
+        assert_eq!(
+            vec![
+                QueryParam::new("foo", "bar", "http://localhost:4000?".len()),
+                QueryParam::new("qux", "420", "http://localhost:4000?foo=bar&".len()),
+            ],
+            url.query_params
+        );
+
+        url.swap_query_param(QueryParam::new(
+            "foobar",
+            "69",
+            "http://localhost:4000?".len(),
+        ));
+
+        assert_eq!(
+            vec![
+                QueryParam::new("foobar", "69", "http://localhost:4000?".len()),
+                QueryParam::new("qux", "420", "http://localhost:4000?foobar=69&".len()),
+            ],
+            url.query_params
+        );
+
+        assert_eq!("http://localhost:4000?foobar=69&qux=420", url.to_string());
+    }
+
+    #[test]
+    fn swap_query_params_dirty() {
+        let input = "http://localhost:4000?=bar&baz=&=";
+
+        let mut url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http://localhost:4000", url.pre);
+
+        assert!(url.path.is_empty());
+
+        assert_eq!(
+            vec![
+                QueryParam::new("", "bar", "http://localhost:4000?".len()),
+                QueryParam::new("baz", "", "http://localhost:4000?=bar&".len()),
+                QueryParam::new("", "", "http://localhost:4000?=bar&baz=&".len()),
+            ],
+            url.query_params
+        );
+
+        url.swap_query_param(QueryParam::new("foo", "69", "http://localhost:4000?".len()));
+
+        assert_eq!(
+            QueryParam::new("foo", "69", "http://localhost:4000?".len()),
+            url.query_params[0]
+        );
+
+        url.swap_query_param(QueryParam::new(
+            "bazooka",
+            "420",
+            "http://localhost:4000?foo=69&".len(),
+        ));
+
+        assert_eq!(
+            QueryParam::new("bazooka", "420", "http://localhost:4000?foo=69&".len()),
+            url.query_params[1]
+        );
+
+        url.swap_query_param(QueryParam::new(
+            "test",
+            "works",
+            "http://localhost:4000?foo=69&bazooka=420&".len(),
+        ));
+
+        assert_eq!(
+            QueryParam::new(
+                "test",
+                "works",
+                "http://localhost:4000?foo=69&bazooka=420&".len()
+            ),
+            url.query_params[2]
+        );
+    }
+
+    #[test]
+    fn add_query_params_trailing_empty() {
+        let input = "http://localhost:4000?";
+
+        let mut url = RequestUrl::parse(input).unwrap();
+
+        url.add_qp_clear_trail("foo", "bar");
+
+        assert_eq!("http://localhost:4000", url.pre);
+        assert_eq!(
+            vec![QueryParam::new(
+                "foo",
+                "bar",
+                "http://localhost:4000?".len()
+            )],
+            url.query_params
+        );
+
+        assert_eq!("http://localhost:4000?foo=bar", url.to_string());
+    }
+
+    #[test]
+    fn adds_query_parameter_with_path() {
+        let input = "http://foo.com/foo/:ID";
+
+        let mut url = RequestUrl::parse(input).unwrap();
+
+        url.add_qp_clear_trail("foo", "bar");
+
+        assert_eq!(
+            vec![QueryParam::new(
+                "foo",
+                "bar",
+                "http://foo.com/foo/:ID?".len()
+            )],
+            url.query_params
+        );
+
+        assert_eq!("http://foo.com/foo/:ID?foo=bar", url.to_string());
+    }
+
+    #[test]
+    fn parse_query_params_trailing() {
+        let input = "http://localhost:4000?foo=bar&";
+
+        let url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http://localhost:4000", url.pre);
+        assert_eq!(
+            vec![QueryParam::new(
+                "foo",
+                "bar",
+                "http://localhost:4000?".len()
+            ),],
+            url.query_params
+        );
+        assert_eq!("&", url.trail);
+
+        let input = "http://localhost:4000?foo=bar&trailing";
+
+        let url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http://localhost:4000", url.pre);
+        assert_eq!(
+            vec![QueryParam::new(
+                "foo",
+                "bar",
+                "http://localhost:4000?".len()
+            ),],
+            url.query_params
+        );
+        assert_eq!("&trailing", url.trail);
     }
 
     #[test]

+ 2 - 1
src-tauri/src/workspace.rs

@@ -1,6 +1,6 @@
 use crate::{
     db::Update,
-    request::{RequestPathUpdate, WorkspaceRequest},
+    request::{RequestPathUpdate, RequestQueryUpdate, WorkspaceRequest},
 };
 use serde::{Deserialize, Serialize};
 use sqlx::prelude::Type;
@@ -97,6 +97,7 @@ pub enum WorkspaceEntryUpdate {
         method: Option<String>,
         url: Option<String>,
         path_params: Option<Vec<RequestPathUpdate>>,
+        query_params: Option<Vec<RequestQueryUpdate>>,
     },
 }
 

+ 39 - 3
src/lib/codemirror.svelte.ts

@@ -6,8 +6,10 @@ import { json as cmJson } from "@codemirror/lang-json";
 import { html as cmHtml } from "@codemirror/lang-html";
 import { getSetting, setSetting } from "./settings.svelte";
 import ls from "./localstorage";
+import type { HttpResponseBody } from "./types";
 
 const jsonExt = cmJson();
+const htmlExt = cmHtml();
 const vimExtension = vim();
 const relativeLines = lineNumbersRelative();
 
@@ -15,6 +17,7 @@ let vimEnabled: boolean = $state(ls.VIM_MODE.get());
 
 export const isVimEnabled = () => vimEnabled;
 
+const langConfig = new Compartment();
 const stateChangeListener = new Compartment();
 const vimConfig = new Compartment();
 const lineWrapConfig = new Compartment();
@@ -50,15 +53,25 @@ export function init(
   id: string,
   lineWrap: boolean,
   vimMode: boolean,
+  type?: HttpResponseBody["type"],
 ): EditorView {
   const extensions = [
     basicSetup,
-    cmHtml(),
-    jsonExt,
     editorPadding,
     stateChangeListener.of(EditorView.updateListener.of(() => {})),
   ];
 
+  const langExtensions = [];
+
+  if (type != null) {
+    const lang = langExt(type);
+    if (lang != null) {
+      langExtensions.push(lang);
+    }
+  }
+
+  extensions.push(langConfig.of(langExtensions));
+
   const vimExtensions = [];
 
   if (vimEnabled && vimMode) {
@@ -89,7 +102,17 @@ export function clearContent(view: EditorView) {
   });
 }
 
-export function setContent(view: EditorView, content?: string) {
+export function setContent(
+  view: EditorView,
+  content?: string,
+  type?: HttpResponseBody["type"],
+) {
+  if (type != null) {
+    const lang = langExt(type);
+    view.dispatch({
+      effects: langConfig.reconfigure(lang != null ? [lang] : []),
+    });
+  }
   view.dispatch({
     changes: {
       from: 0,
@@ -166,3 +189,16 @@ function relativeLineNumbers(lineNo: number, state: EditorState) {
   // Relative number for all other lines
   return Math.abs(cursorLine - lineNo).toString();
 }
+
+function langExt(type: HttpResponseBody["type"]): Extension | null {
+  switch (type) {
+    case "TextPlain":
+      return null;
+    case "TextHtml": {
+      return htmlExt;
+    }
+    case "Json": {
+      return jsonExt;
+    }
+  }
+}

+ 0 - 1
src/lib/components/AuthParams.svelte

@@ -69,7 +69,6 @@
     ></Input>
     <p>Password</p>
     <Input
-      type="password"
       class="w-80"
       bind:value={auth.params.value.password}
       oninput={() => updateAuthParams(auth.id)}

+ 2 - 2
src/lib/components/BodyEditor.svelte

@@ -14,7 +14,7 @@
   } from "$lib/codemirror.svelte";
   import { init } from "$lib/codemirror.svelte";
 
-  let { input = null, onStateChange } = $props();
+  let { input = null, onStateChange, type } = $props();
 
   let view: EditorView;
 
@@ -26,7 +26,7 @@
 
   $effect(() => {
     if (input != null && input !== view.state.doc.toString()) {
-      setContent(view, input);
+      setContent(view, input, type);
     }
   });
 </script>

+ 1 - 1
src/lib/components/Response.svelte

@@ -29,7 +29,7 @@
       response.body != null &&
       response.body.content !== view.state.doc.toString()
     ) {
-      setContent(view, response.body.content);
+      setContent(view, response.body.content, response.body.type);
     }
   });
 

+ 15 - 0
src/lib/components/SidebarEntry.svelte

@@ -4,6 +4,7 @@
     state as _state,
     createCollection,
     createRequest,
+    traverseEntries,
     selectEntry,
   } from "$lib/state.svelte";
   import Self from "./SidebarEntry.svelte";
@@ -53,6 +54,20 @@
       class="w-full cursor-pointer py-1"
       onclick={(e) => {
         e.stopPropagation();
+
+        if (e.ctrlKey) {
+          if (_state.indexes[id].open) {
+            traverseEntries(id, (entry) => {
+              entry.open = false;
+            });
+          } else {
+            traverseEntries(id, (entry) => {
+              entry.open = true;
+            });
+          }
+          return;
+        }
+
         selectEntry(id);
         onSelect();
         setSetting("lastEntry", _state.indexes[id]);

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

@@ -130,10 +130,13 @@
       url +=
         "?" +
         _state.entry
-          .workingUrl!!.query_params.map((p) => `${p[0]}=${p[1]}`)
+          .workingUrl!!.query_params.map((p) => `${p.key}=${p.value}`)
           .join("&");
-    } else if (_state.entry.workingUrl!!.has_query) {
-      url += "?";
+    } else if (
+      _state.entry.workingUrl!!.trail &&
+      _state.entry.workingUrl.trail.length
+    ) {
+      url += _state.entry.workingUrl.trail;
     }
 
     return url;
@@ -324,12 +327,12 @@
                   <div class="grid grid-cols-2 gap-2 text-sm">
                     {#each _state.entry.workingUrl.query_params as param}
                       <Input
-                        bind:value={param[0]}
+                        bind:value={param.key}
                         placeholder="key"
                         oninput={() => handleUrlUpdate()}
                       />
                       <Input
-                        bind:value={param[1]}
+                        bind:value={param.value}
                         placeholder="value"
                         oninput={() => handleUrlUpdate()}
                       />
@@ -391,10 +394,12 @@
                 <Tabs.Content value="json">
                   <BodyEditor
                     input={_state.entry.body?.body}
+                    type={_state.entry.body?.content_type}
                     onStateChange={(update) => {
                       if (
                         update.docChanged &&
-                        _state.entry.body?.body !== update.state.doc.toString()
+                        _state.entry!!.body?.body !==
+                          update.state.doc.toString()
                       ) {
                         updateBodyContent(update.state.doc.toString(), "Json");
                       }

+ 25 - 5
src/lib/state.svelte.ts

@@ -8,7 +8,7 @@ import type {
   EnvVariable,
   RequestUrl,
   RequestHeader,
-  RequestPathParam,
+  PathParam,
   Authentication,
   AuthType,
   HttpResponse,
@@ -40,7 +40,7 @@ export type WorkspaceState = {
   /**
    * All workspace entries.
    */
-  indexes: Record<number, WorkspaceEntryBase>;
+  indexes: Record<number, WorkspaceEntry>;
 
   /**
    * Currently selected workspace environments.
@@ -108,6 +108,26 @@ function reset() {
   state.responses = {};
 }
 
+/**
+ * Given a root entry, traverse its children depth first and call `fn` on each, including
+ * the root.
+ */
+export function traverseEntries(id: number, fn: (e: WorkspaceEntry) => void) {
+  if (state.indexes[id] == null) {
+    return;
+  }
+
+  fn(state.indexes[id]);
+
+  if (state.children[id] == null) {
+    return;
+  }
+
+  for (const child of state.children[id]) {
+    traverseEntries(child, fn);
+  }
+}
+
 export async function selectEnvironment(
   id: number | null,
   save: boolean = true,
@@ -549,8 +569,8 @@ export async function updateBodyContent(body: string, ct: string) {
         },
       },
     });
-    state.entry.body.body = body;
-    state.entry.body.content_type = ct;
+    state.entry!!.body.body = body;
+    state.entry!!.body.content_type = ct;
   } else {
     const b = await invoke("insert_request_body", {
       entryId: state.entry!!.id,
@@ -645,5 +665,5 @@ type WorkspaceRequestResponse = {
   url: string;
   body: RequestBody | null;
   headers: RequestHeader[];
-  path_params: RequestPathParam[];
+  path_params: PathParam[];
 };

+ 13 - 9
src/lib/types.ts

@@ -30,10 +30,8 @@ export type WorkspaceRequest = WorkspaceEntryBase & {
   url: string;
   body: RequestBody | null;
   headers: RequestHeader[];
-  path: RequestPathParam[];
-
-  auth: number | null;
-  auth_inherit: boolean;
+  path: PathParam[];
+  query: QueryParam[];
 
   // Display fields
 
@@ -45,8 +43,8 @@ export type RequestUrl = {
   scheme: string;
   host: string;
   path: PathSegment[];
-  query_params: string[][];
-  has_query: boolean;
+  query_params: QueryParam[];
+  trail: string;
 };
 
 export type UrlErrorType = "Parse" | "DuplicatePath" | "Db";
@@ -62,12 +60,18 @@ export type RequestHeader = {
   value: string;
 };
 
-export type RequestPathParam = {
+export type PathParam = {
   position: number;
   name: string;
   value: string;
 };
 
+export type QueryParam = {
+  pos: number;
+  key: string;
+  value: string;
+};
+
 export type PathSegment = {
   type: "Static" | "Dynamic";
   value: string;
@@ -89,8 +93,8 @@ export type WorkspaceCreateRequest = {
 
 export type RequestBody = {
   id: number;
-  content: string;
-  ty: string;
+  body: string;
+  content_type: string;
 };
 
 export type WorkspaceEnvironment = {