Template Tag Function Editor (#67)

![CleanShot 2024-08-15 at 16 53
09@2x](https://github.com/user-attachments/assets/8c0eb655-1daf-4dc8-811f-f606c770f7dc)
This commit is contained in:
Gregory Schier
2024-08-16 08:31:19 -07:00
committed by GitHub
parent a7f0fadeae
commit aa85ecb618
62 changed files with 1339 additions and 437 deletions

View File

@@ -18,6 +18,7 @@ module.exports = {
'plugin-runtime-types/**/*', 'plugin-runtime-types/**/*',
'src-tauri/**/*', 'src-tauri/**/*',
'plugins/**/*', 'plugins/**/*',
'tailwind.config.cjs',
], ],
settings: { settings: {
react: { react: {

21
package-lock.json generated
View File

@@ -38,6 +38,7 @@
"focus-trap-react": "^10.1.1", "focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0", "format-graphql": "^1.4.0",
"framer-motion": "^9.0.4", "framer-motion": "^9.0.4",
"jotai": "^2.9.3",
"lucide-react": "^0.309.0", "lucide-react": "^0.309.0",
"mime": "^4.0.1", "mime": "^4.0.1",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
@@ -7423,6 +7424,26 @@
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
}, },
"node_modules/jotai": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.9.3.tgz",
"integrity": "sha512-IqMWKoXuEzWSShjd9UhalNsRGbdju5G2FrqNLQJT+Ih6p41VNYe2sav5hnwQx4HJr25jq9wRqvGSWGviGG6Gjw==",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=17.0.0",
"react": ">=17.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/js-base64": { "node_modules/js-base64": {
"version": "3.7.7", "version": "3.7.7",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz",

View File

@@ -39,9 +39,9 @@
"@tauri-apps/plugin-clipboard-manager": "^2.0.0-rc.0", "@tauri-apps/plugin-clipboard-manager": "^2.0.0-rc.0",
"@tauri-apps/plugin-dialog": "^2.0.0-rc.0", "@tauri-apps/plugin-dialog": "^2.0.0-rc.0",
"@tauri-apps/plugin-fs": "^2.0.0-rc.0", "@tauri-apps/plugin-fs": "^2.0.0-rc.0",
"@tauri-apps/plugin-log": "^2.0.0-rc.0",
"@tauri-apps/plugin-os": "^2.0.0-rc.0", "@tauri-apps/plugin-os": "^2.0.0-rc.0",
"@tauri-apps/plugin-shell": "^2.0.0-rc.0", "@tauri-apps/plugin-shell": "^2.0.0-rc.0",
"@tauri-apps/plugin-log": "^2.0.0-rc.0",
"@yaakapp/api": "^0.1.6", "@yaakapp/api": "^0.1.6",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"classnames": "^2.3.2", "classnames": "^2.3.2",
@@ -53,6 +53,7 @@
"focus-trap-react": "^10.1.1", "focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0", "format-graphql": "^1.4.0",
"framer-motion": "^9.0.4", "framer-motion": "^9.0.4",
"jotai": "^2.9.3",
"lucide-react": "^0.309.0", "lucide-react": "^0.309.0",
"mime": "^4.0.1", "mime": "^4.0.1",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",

View File

@@ -1,12 +1,12 @@
{ {
"name": "@yaakapp/api", "name": "@yaakapp/api",
"version": "0.1.0-beta.4", "version": "0.1.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@yaakapp/api", "name": "@yaakapp/api",
"version": "0.1.0-beta.4", "version": "0.1.6",
"dependencies": { "dependencies": {
"@types/node": "^22.0.0" "@types/node": "^22.0.0"
}, },

View File

@@ -3,7 +3,7 @@ import { HttpRequestActionPlugin } from './httpRequestAction';
import { ImporterPlugin } from './import'; import { ImporterPlugin } from './import';
import { ThemePlugin } from './theme'; import { ThemePlugin } from './theme';
export { YaakContext } from './context'; export type { YaakContext } from './context';
/** /**
* The global structure of a Yaak plugin * The global structure of a Yaak plugin

19
src-tauri/Cargo.lock generated
View File

@@ -4919,9 +4919,9 @@ dependencies = [
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.205" version = "1.0.208"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e33aedb1a7135da52b7c21791455563facbbcc43d0f0f66165b42c21b3dfb150" checksum = "cff085d2cb684faa248efb494c39b68e522822ac0de72ccf08109abde717cfb2"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
@@ -4949,9 +4949,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.205" version = "1.0.208"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "692d6f5ac90220161d6774db30c662202721e64aed9058d2c394f451261420c1" checksum = "24008e81ff7613ed8e5ba0cfaf24e2c2f1e5b8a0495711e44fcd4882fca62bcf"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -4971,9 +4971,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.122" version = "1.0.125"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784b6203951c57ff748476b126ccb5e8e2959a5c19e5c617ab1956be3dbc68da" checksum = "83c8e735a073ccf5be70aa8066aa984eaf2fa000db6c8d0100ae605b366d31ed"
dependencies = [ dependencies = [
"itoa 1.0.11", "itoa 1.0.11",
"memchr", "memchr",
@@ -5844,9 +5844,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-clipboard-manager" name = "tauri-plugin-clipboard-manager"
version = "2.0.0-rc.0" version = "2.1.0-beta.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a26868f7e05a09673e4172d23acb82cd48911cca092f0e8d06179a69e5024c" checksum = "becbc5a692e842f8d6a7ab5e490c3c36d267b5c3d5bf4b6a0cdd039d7df25569"
dependencies = [ dependencies = [
"arboard", "arboard",
"image 0.24.9", "image 0.24.9",
@@ -7649,6 +7649,9 @@ name = "yaak_templates"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"log", "log",
"serde",
"serde_json",
"ts-rs",
] ]
[[package]] [[package]]

View File

@@ -45,7 +45,7 @@ serde_json = { version = "1.0.116", features = ["raw_value"] }
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
tauri = { workspace = true, features = ["unstable"] } tauri = { workspace = true, features = ["unstable"] }
tauri-plugin-shell = { workspace = true } tauri-plugin-shell = { workspace = true }
tauri-plugin-clipboard-manager = "2.0.0-rc.0" tauri-plugin-clipboard-manager = "2.1.0-beta.7"
tauri-plugin-dialog = "2.0.0-rc.0" tauri-plugin-dialog = "2.0.0-rc.0"
tauri-plugin-fs = "2.0.0-rc.0" tauri-plugin-fs = "2.0.0-rc.0"
tauri-plugin-log = { version = "2.0.0-rc.0", features = ["colored"] } tauri-plugin-log = { version = "2.0.0-rc.0", features = ["colored"] }

View File

@@ -35,7 +35,7 @@ use crate::export_resources::{get_workspace_export_resources, WorkspaceExportRes
use crate::grpc::metadata_to_map; use crate::grpc::metadata_to_map;
use crate::http_request::send_http_request; use crate::http_request::send_http_request;
use crate::notifications::YaakNotifier; use crate::notifications::YaakNotifier;
use crate::render::{render_request, variables_from_environment}; use crate::render::{render_request, render_template, variables_from_environment};
use crate::updates::{UpdateMode, YaakUpdater}; use crate::updates::{UpdateMode, YaakUpdater};
use crate::window_menu::app_menu; use crate::window_menu::app_menu;
use yaak_models::models::{ use yaak_models::models::{
@@ -60,6 +60,7 @@ use yaak_plugin_runtime::events::{
GetHttpRequestByIdResponse, InternalEvent, InternalEventPayload, RenderHttpRequestResponse, GetHttpRequestByIdResponse, InternalEvent, InternalEventPayload, RenderHttpRequestResponse,
SendHttpRequestResponse, SendHttpRequestResponse,
}; };
use yaak_templates::{parse_and_render, Parser, Tokens};
mod analytics; mod analytics;
mod export_resources; mod export_resources;
@@ -99,6 +100,38 @@ async fn cmd_metadata(app_handle: AppHandle) -> Result<AppMetaData, ()> {
}) })
} }
#[tauri::command]
async fn cmd_parse_template(template: &str) -> Result<Tokens, String> {
Ok(Parser::new(template).parse())
}
#[tauri::command]
async fn cmd_template_tokens_to_string(tokens: Tokens) -> Result<String, String> {
Ok(tokens.to_string())
}
#[tauri::command]
async fn cmd_render_template(
window: WebviewWindow,
template: &str,
workspace_id: &str,
environment_id: Option<&str>,
) -> Result<String, String> {
let environment = match environment_id {
Some(id) => Some(
get_environment(&window, id)
.await
.map_err(|e| e.to_string())?,
),
None => None,
};
let workspace = get_workspace(&window, &workspace_id)
.await
.map_err(|e| e.to_string())?;
let rendered = render_template(template, &workspace, environment.as_ref());
Ok(rendered)
}
#[tauri::command] #[tauri::command]
async fn cmd_dismiss_notification( async fn cmd_dismiss_notification(
window: WebviewWindow, window: WebviewWindow,
@@ -1641,6 +1674,9 @@ pub fn run() {
cmd_delete_http_response, cmd_delete_http_response,
cmd_delete_workspace, cmd_delete_workspace,
cmd_dismiss_notification, cmd_dismiss_notification,
cmd_parse_template,
cmd_template_tokens_to_string,
cmd_render_template,
cmd_duplicate_grpc_request, cmd_duplicate_grpc_request,
cmd_duplicate_http_request, cmd_duplicate_http_request,
cmd_export_data, cmd_export_data,

View File

@@ -1,10 +1,15 @@
use std::collections::HashMap;
use serde_json::Value;
use crate::template_fns::timestamp; use crate::template_fns::timestamp;
use yaak_templates::parse_and_render; use serde_json::Value;
use std::collections::HashMap;
use yaak_models::models::{ use yaak_models::models::{
Environment, EnvironmentVariable, HttpRequest, HttpRequestHeader, HttpUrlParameter, Workspace, Environment, EnvironmentVariable, HttpRequest, HttpRequestHeader, HttpUrlParameter, Workspace,
}; };
use yaak_templates::parse_and_render;
pub fn render_template(template: &str, w: &Workspace, e: Option<&Environment>) -> String {
let vars = &variables_from_environment(w, e);
render(template, vars)
}
pub fn render_request(r: &HttpRequest, w: &Workspace, e: Option<&Environment>) -> HttpRequest { pub fn render_request(r: &HttpRequest, w: &Workspace, e: Option<&Environment>) -> HttpRequest {
let r = r.clone(); let r = r.clone();

View File

@@ -5,3 +5,6 @@ edition = "2021"
[dependencies] [dependencies]
log = "0.4.22" log = "0.4.22"
serde = { version = "1.0.208", features = ["derive"] }
serde_json = "1.0.125"
ts-rs = { version = "9.0.1" }

View File

@@ -0,0 +1,6 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Tell ts-rs where to generate types to
println!("cargo:rustc-env=TS_RS_EXPORT_DIR=../../src-web/gen");
Ok(())
}

View File

@@ -2,6 +2,4 @@ pub mod parser;
pub mod renderer; pub mod renderer;
pub use parser::*; pub use parser::*;
pub use renderer::*; pub use renderer::*;
pub fn template_foo() {}

View File

@@ -1,23 +1,92 @@
#[derive(Clone, PartialEq, Debug)] use serde::{Deserialize, Serialize};
use std::fmt::Display;
use ts_rs::TS;
#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct Tokens {
pub tokens: Vec<Token>,
}
impl Display for Tokens {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = self
.tokens
.iter()
.map(|t| t.to_string())
.collect::<Vec<String>>()
.join("");
write!(f, "{}", str)
}
}
#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct FnArg { pub struct FnArg {
pub name: String, pub name: String,
pub value: Val, pub value: Val,
} }
#[derive(Clone, PartialEq, Debug)] impl Display for FnArg {
pub enum Val { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Str(String), let str = format!("{}={}", self.name, self.value);
Var(String), write!(f, "{}", str)
Fn { name: String, args: Vec<FnArg> }, }
} }
#[derive(Clone, PartialEq, Debug)] #[derive(Clone, PartialEq, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export)]
pub enum Val {
Str { text: String },
Var { name: String },
Fn { name: String, args: Vec<FnArg> },
Null,
}
impl Display for Val {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = match self {
Val::Str { text } => format!(r#""{}""#, text.to_string().replace(r#"""#, r#"\""#)),
Val::Var { name } => name.to_string(),
Val::Fn { name, args } => {
format!(
"{name}({})",
args.iter()
.filter_map(|a| match a.value.clone() {
Val::Null => None,
_ => Some(a.to_string()),
})
.collect::<Vec<String>>()
.join(", ")
)
}
Val::Null => "null".to_string(),
};
write!(f, "{}", str)
}
}
#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export)]
pub enum Token { pub enum Token {
Raw(String), Raw { text: String },
Tag(Val), Tag { val: Val },
Eof, Eof,
} }
impl Display for Token {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = match self {
Token::Raw { text } => text.to_string(),
Token::Tag { val } => format!("${{[ {} ]}}", val.to_string()),
Token::Eof => "".to_string(),
};
write!(f, "{}", str)
}
}
// Template Syntax // Template Syntax
// //
// ${[ my_var ]} // ${[ my_var ]}
@@ -42,7 +111,7 @@ impl Parser {
} }
} }
pub fn parse(&mut self) -> Vec<Token> { pub fn parse(&mut self) -> Tokens {
let start_pos = self.pos; let start_pos = self.pos;
while self.pos < self.chars.len() { while self.pos < self.chars.len() {
@@ -65,7 +134,9 @@ impl Parser {
} }
self.push_token(Token::Eof); self.push_token(Token::Eof);
self.tokens.clone() Tokens {
tokens: self.tokens.clone(),
}
} }
fn parse_tag(&mut self) -> Option<Token> { fn parse_tag(&mut self) -> Option<Token> {
@@ -85,7 +156,7 @@ impl Parser {
return None; return None;
} }
Some(Token::Tag(val)) Some(Token::Tag { val })
} }
#[allow(dead_code)] #[allow(dead_code)]
@@ -103,9 +174,13 @@ impl Parser {
if let Some((name, args)) = self.parse_fn() { if let Some((name, args)) = self.parse_fn() {
Some(Val::Fn { name, args }) Some(Val::Fn { name, args })
} else if let Some(v) = self.parse_ident() { } else if let Some(v) = self.parse_ident() {
Some(Val::Var(v)) if v == "null" {
Some(Val::Null)
} else {
Some(Val::Var { name: v })
}
} else if let Some(v) = self.parse_string() { } else if let Some(v) = self.parse_string() {
Some(Val::Str(v)) Some(Val::Str { text: v })
} else { } else {
None None
} }
@@ -145,7 +220,7 @@ impl Parser {
// Fn closed immediately // Fn closed immediately
self.skip_whitespace(); self.skip_whitespace();
if self.match_str(")") { if self.match_str(")") {
return Some(args) return Some(args);
} }
while self.pos < self.chars.len() { while self.pos < self.chars.len() {
@@ -183,7 +258,7 @@ impl Parser {
} }
} }
return Some(args); Some(args)
} }
fn parse_ident(&mut self) -> Option<String> { fn parse_ident(&mut self) -> Option<String> {
@@ -209,7 +284,7 @@ impl Parser {
return None; return None;
} }
return Some(text); Some(text)
} }
fn parse_string(&mut self) -> Option<String> { fn parse_string(&mut self) -> Option<String> {
@@ -246,7 +321,7 @@ impl Parser {
return None; return None;
} }
return Some(text); Some(text)
} }
fn skip_whitespace(&mut self) { fn skip_whitespace(&mut self) {
@@ -274,7 +349,9 @@ impl Parser {
fn push_token(&mut self, token: Token) { fn push_token(&mut self, token: Token) {
// Push any text we've accumulated // Push any text we've accumulated
if !self.curr_text.is_empty() { if !self.curr_text.is_empty() {
let text_token = Token::Raw(self.curr_text.clone()); let text_token = Token::Raw {
text: self.curr_text.clone(),
};
self.tokens.push(text_token); self.tokens.push(text_token);
self.curr_text.clear(); self.curr_text.clear();
} }
@@ -303,14 +380,20 @@ impl Parser {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::Val::Null;
use crate::*; use crate::*;
#[test] #[test]
fn var_simple() { fn var_simple() {
let mut p = Parser::new("${[ foo ]}"); let mut p = Parser::new("${[ foo ]}");
assert_eq!( assert_eq!(
p.parse(), p.parse().tokens,
vec![Token::Tag(Val::Var("foo".into())), Token::Eof] vec![
Token::Tag {
val: Val::Var { name: "foo".into() }
},
Token::Eof
]
); );
} }
@@ -318,8 +401,13 @@ mod tests {
fn var_multiple_names_invalid() { fn var_multiple_names_invalid() {
let mut p = Parser::new("${[ foo bar ]}"); let mut p = Parser::new("${[ foo bar ]}");
assert_eq!( assert_eq!(
p.parse(), p.parse().tokens,
vec![Token::Raw("${[ foo bar ]}".into()), Token::Eof] vec![
Token::Raw {
text: "${[ foo bar ]}".into()
},
Token::Eof
]
); );
} }
@@ -327,8 +415,15 @@ mod tests {
fn tag_string() { fn tag_string() {
let mut p = Parser::new(r#"${[ "foo \"bar\" baz" ]}"#); let mut p = Parser::new(r#"${[ "foo \"bar\" baz" ]}"#);
assert_eq!( assert_eq!(
p.parse(), p.parse().tokens,
vec![Token::Tag(Val::Str(r#"foo "bar" baz"#.into())), Token::Eof] vec![
Token::Tag {
val: Val::Str {
text: r#"foo "bar" baz"#.into()
}
},
Token::Eof
]
); );
} }
@@ -336,11 +431,17 @@ mod tests {
fn var_surrounded() { fn var_surrounded() {
let mut p = Parser::new("Hello ${[ foo ]}!"); let mut p = Parser::new("Hello ${[ foo ]}!");
assert_eq!( assert_eq!(
p.parse(), p.parse().tokens,
vec![ vec![
Token::Raw("Hello ".to_string()), Token::Raw {
Token::Tag(Val::Var("foo".into())), text: "Hello ".to_string()
Token::Raw("!".to_string()), },
Token::Tag {
val: Val::Var { name: "foo".into() }
},
Token::Raw {
text: "!".to_string()
},
Token::Eof, Token::Eof,
] ]
); );
@@ -350,12 +451,14 @@ mod tests {
fn fn_simple() { fn fn_simple() {
let mut p = Parser::new("${[ foo() ]}"); let mut p = Parser::new("${[ foo() ]}");
assert_eq!( assert_eq!(
p.parse(), p.parse().tokens,
vec![ vec![
Token::Tag(Val::Fn { Token::Tag {
name: "foo".into(), val: Val::Fn {
args: Vec::new(), name: "foo".into(),
}), args: Vec::new(),
}
},
Token::Eof Token::Eof
] ]
); );
@@ -365,15 +468,17 @@ mod tests {
fn fn_ident_arg() { fn fn_ident_arg() {
let mut p = Parser::new("${[ foo(a=bar) ]}"); let mut p = Parser::new("${[ foo(a=bar) ]}");
assert_eq!( assert_eq!(
p.parse(), p.parse().tokens,
vec![ vec![
Token::Tag(Val::Fn { Token::Tag {
name: "foo".into(), val: Val::Fn {
args: vec![FnArg { name: "foo".into(),
name: "a".into(), args: vec![FnArg {
value: Val::Var("bar".into()) name: "a".into(),
}], value: Val::Var { name: "bar".into() }
}), }],
}
},
Token::Eof Token::Eof
] ]
); );
@@ -383,25 +488,27 @@ mod tests {
fn fn_ident_args() { fn fn_ident_args() {
let mut p = Parser::new("${[ foo(a=bar,b = baz, c =qux ) ]}"); let mut p = Parser::new("${[ foo(a=bar,b = baz, c =qux ) ]}");
assert_eq!( assert_eq!(
p.parse(), p.parse().tokens,
vec![ vec![
Token::Tag(Val::Fn { Token::Tag {
name: "foo".into(), val: Val::Fn {
args: vec![ name: "foo".into(),
FnArg { args: vec![
name: "a".into(), FnArg {
value: Val::Var("bar".into()) name: "a".into(),
}, value: Val::Var { name: "bar".into() }
FnArg { },
name: "b".into(), FnArg {
value: Val::Var("baz".into()) name: "b".into(),
}, value: Val::Var { name: "baz".into() }
FnArg { },
name: "c".into(), FnArg {
value: Val::Var("qux".into()) name: "c".into(),
}, value: Val::Var { name: "qux".into() }
], },
}), ],
}
},
Token::Eof Token::Eof
] ]
); );
@@ -411,25 +518,29 @@ mod tests {
fn fn_mixed_args() { fn fn_mixed_args() {
let mut p = Parser::new(r#"${[ foo(aaa=bar,bb="baz \"hi\"", c=qux ) ]}"#); let mut p = Parser::new(r#"${[ foo(aaa=bar,bb="baz \"hi\"", c=qux ) ]}"#);
assert_eq!( assert_eq!(
p.parse(), p.parse().tokens,
vec![ vec![
Token::Tag(Val::Fn { Token::Tag {
name: "foo".into(), val: Val::Fn {
args: vec![ name: "foo".into(),
FnArg { args: vec![
name: "aaa".into(), FnArg {
value: Val::Var("bar".into()) name: "aaa".into(),
}, value: Val::Var { name: "bar".into() }
FnArg { },
name: "bb".into(), FnArg {
value: Val::Str(r#"baz "hi""#.into()) name: "bb".into(),
}, value: Val::Str {
FnArg { text: r#"baz "hi""#.into()
name: "c".into(), }
value: Val::Var("qux".into()) },
}, FnArg {
], name: "c".into(),
}), value: Val::Var { name: "qux".into() }
},
],
}
},
Token::Eof Token::Eof
] ]
); );
@@ -439,18 +550,20 @@ mod tests {
fn fn_nested() { fn fn_nested() {
let mut p = Parser::new("${[ foo(b=bar()) ]}"); let mut p = Parser::new("${[ foo(b=bar()) ]}");
assert_eq!( assert_eq!(
p.parse(), p.parse().tokens,
vec![ vec![
Token::Tag(Val::Fn { Token::Tag {
name: "foo".into(), val: Val::Fn {
args: vec![FnArg { name: "foo".into(),
name: "b".into(), args: vec![FnArg {
value: Val::Fn { name: "b".into(),
name: "bar".into(), value: Val::Fn {
args: vec![], name: "bar".into(),
} args: vec![],
}], }
}), }],
}
},
Token::Eof Token::Eof
] ]
); );
@@ -460,35 +573,134 @@ mod tests {
fn fn_nested_args() { fn fn_nested_args() {
let mut p = Parser::new(r#"${[ outer(a=inner(a=foo, b="i"), c="o") ]}"#); let mut p = Parser::new(r#"${[ outer(a=inner(a=foo, b="i"), c="o") ]}"#);
assert_eq!( assert_eq!(
p.parse(), p.parse().tokens,
vec![ vec![
Token::Tag(Val::Fn { Token::Tag {
name: "outer".into(), val: Val::Fn {
args: vec![ name: "outer".into(),
FnArg { args: vec![
name: "a".into(), FnArg {
value: Val::Fn { name: "a".into(),
name: "inner".into(), value: Val::Fn {
args: vec![ name: "inner".into(),
FnArg { args: vec![
name: "a".into(), FnArg {
value: Val::Var("foo".into()) name: "a".into(),
}, value: Val::Var { name: "foo".into() }
FnArg { },
name: "b".into(), FnArg {
value: Val::Str("i".into()), name: "b".into(),
}, value: Val::Str { text: "i".into() },
], },
} ],
}, }
FnArg { },
name: "c".into(), FnArg {
value: Val::Str("o".into()) name: "c".into(),
}, value: Val::Str { text: "o".into() }
], },
}), ],
}
},
Token::Eof Token::Eof
] ]
); );
} }
#[test]
fn token_display_var() {
assert_eq!(
Val::Var {
name: "foo".to_string()
}
.to_string(),
"foo"
);
}
#[test]
fn token_display_str() {
assert_eq!(
Val::Str {
text: r#"Hello "You""#.to_string()
}
.to_string(),
r#""Hello \"You\"""#
);
}
#[test]
fn token_null_fn_arg() {
assert_eq!(
Val::Fn {
name: "fn".to_string(),
args: vec![
FnArg {
name: "n".to_string(),
value: Null,
},
FnArg {
name: "a".to_string(),
value: Val::Str {
text: "aaa".to_string()
}
}
]
}
.to_string(),
r#"fn(a="aaa")"#
);
}
#[test]
fn token_display_fn() {
assert_eq!(
Token::Tag {
val: Val::Fn {
name: "foo".to_string(),
args: vec![
FnArg {
name: "arg".to_string(),
value: Val::Str {
text: "v".to_string()
}
},
FnArg {
name: "arg2".to_string(),
value: Val::Var {
name: "my_var".to_string()
}
}
]
}
}
.to_string(),
r#"${[ foo(arg="v", arg2=my_var) ]}"#
);
}
#[test]
fn tokens_display() {
assert_eq!(
Tokens {
tokens: vec![
Token::Tag {
val: Val::Var {
name: "my_var".to_string()
}
},
Token::Raw {
text: " Some cool text ".to_string(),
},
Token::Tag {
val: Val::Str {
text: "Hello World".to_string()
}
}
]
}
.to_string(),
r#"${[ my_var ]} Some cool text ${[ "Hello World" ]}"#
);
}
} }

View File

@@ -1,4 +1,4 @@
use crate::{FnArg, Parser, Token, Val}; use crate::{FnArg, Parser, Token, Tokens, Val};
use log::warn; use log::warn;
use std::collections::HashMap; use std::collections::HashMap;
@@ -15,27 +15,27 @@ pub fn parse_and_render(
} }
pub fn render( pub fn render(
tokens: Vec<Token>, tokens: Tokens,
vars: &HashMap<String, String>, vars: &HashMap<String, String>,
cb: Option<TemplateCallback>, cb: Option<TemplateCallback>,
) -> String { ) -> String {
let mut doc_str: Vec<String> = Vec::new(); let mut doc_str: Vec<String> = Vec::new();
for t in tokens { for t in tokens.tokens {
match t { match t {
Token::Raw(s) => doc_str.push(s), Token::Raw { text } => doc_str.push(text),
Token::Tag(val) => doc_str.push(render_tag(val, &vars, cb)), Token::Tag { val } => doc_str.push(render_tag(val, &vars, cb)),
Token::Eof => {} Token::Eof => {}
} }
} }
return doc_str.join(""); doc_str.join("")
} }
fn render_tag(val: Val, vars: &HashMap<String, String>, cb: Option<TemplateCallback>) -> String { fn render_tag(val: Val, vars: &HashMap<String, String>, cb: Option<TemplateCallback>) -> String {
match val { match val {
Val::Str(s) => s.into(), Val::Str { text } => text.into(),
Val::Var(name) => match vars.get(name.as_str()) { Val::Var { name } => match vars.get(name.as_str()) {
Some(v) => v.to_string(), Some(v) => v.to_string(),
None => "".into(), None => "".into(),
}, },
@@ -46,14 +46,14 @@ fn render_tag(val: Val, vars: &HashMap<String, String>, cb: Option<TemplateCallb
.map(|a| match a { .map(|a| match a {
FnArg { FnArg {
name, name,
value: Val::Str(s), value: Val::Str { text },
} => (name.to_string(), s.to_string()), } => (name.to_string(), text.to_string()),
FnArg { FnArg {
name, name,
value: Val::Var(i), value: Val::Var { name: var_name },
} => ( } => (
name.to_string(), name.to_string(),
vars.get(i.as_str()).unwrap_or(&empty).to_string(), vars.get(var_name.as_str()).unwrap_or(&empty).to_string(),
), ),
FnArg { name, value: val } => { FnArg { name, value: val } => {
(name.to_string(), render_tag(val.clone(), vars, cb)) (name.to_string(), render_tag(val.clone(), vars, cb))
@@ -64,13 +64,17 @@ fn render_tag(val: Val, vars: &HashMap<String, String>, cb: Option<TemplateCallb
Some(cb) => match cb(name.as_str(), resolved_args.clone()) { Some(cb) => match cb(name.as_str(), resolved_args.clone()) {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
warn!("Failed to run template callback {}({:?}): {}", name, resolved_args, e); warn!(
"Failed to run template callback {}({:?}): {}",
name, resolved_args, e
);
"".to_string() "".to_string()
} }
}, },
None => "".into(), None => "".into(),
} }
} }
Val::Null => "".into()
} }
} }
@@ -147,7 +151,7 @@ mod tests {
result.to_string() result.to_string()
); );
} }
#[test] #[test]
fn render_fn_err() { fn render_fn_err() {
let vars = HashMap::new(); let vars = HashMap::new();

View File

@@ -1,18 +1,19 @@
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import type { Model } from '@yaakapp/api'; import type { Model } from '@yaakapp/api';
import { useSetAtom } from 'jotai';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useEnsureActiveCookieJar, useMigrateActiveCookieJarId } from '../hooks/useActiveCookieJar'; import { useEnsureActiveCookieJar, useMigrateActiveCookieJarId } from '../hooks/useActiveCookieJar';
import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast'; import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast';
import { cookieJarsQueryKey } from '../hooks/useCookieJars'; import { cookieJarsQueryKey } from '../hooks/useCookieJars';
import { useCopy } from '../hooks/useCopy'; import { useCopy } from '../hooks/useCopy';
import { environmentsQueryKey } from '../hooks/useEnvironments'; import { environmentsAtom } from '../hooks/useEnvironments';
import { foldersQueryKey } from '../hooks/useFolders'; import { foldersQueryKey } from '../hooks/useFolders';
import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections'; import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections';
import { grpcEventsQueryKey } from '../hooks/useGrpcEvents'; import { grpcEventsQueryKey } from '../hooks/useGrpcEvents';
import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests'; import { grpcRequestsAtom } from '../hooks/useGrpcRequests';
import { useHotKey } from '../hooks/useHotKey'; import { useHotKey } from '../hooks/useHotKey';
import { httpRequestsQueryKey } from '../hooks/useHttpRequests'; import { httpRequestsAtom } from '../hooks/useHttpRequests';
import { httpResponsesQueryKey } from '../hooks/useHttpResponses'; import { httpResponsesQueryKey } from '../hooks/useHttpResponses';
import { keyValueQueryKey } from '../hooks/useKeyValue'; import { keyValueQueryKey } from '../hooks/useKeyValue';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent'; import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
@@ -25,7 +26,7 @@ import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { settingsQueryKey, useSettings } from '../hooks/useSettings'; import { settingsQueryKey, useSettings } from '../hooks/useSettings';
import { useSyncThemeToDocument } from '../hooks/useSyncThemeToDocument'; import { useSyncThemeToDocument } from '../hooks/useSyncThemeToDocument';
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette'; import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
import { workspacesQueryKey } from '../hooks/useWorkspaces'; import { workspacesAtom } from '../hooks/useWorkspaces';
import { useZoom } from '../hooks/useZoom'; import { useZoom } from '../hooks/useZoom';
import { extractKeyValue } from '../lib/keyValueStore'; import { extractKeyValue } from '../lib/keyValueStore';
import { modelsEq } from '../lib/models'; import { modelsEq } from '../lib/models';
@@ -64,25 +65,22 @@ export function GlobalHooks() {
windowLabel: string; windowLabel: string;
} }
const setWorkspaces = useSetAtom(workspacesAtom);
const setHttpRequests = useSetAtom(httpRequestsAtom);
const setGrpcRequests = useSetAtom(grpcRequestsAtom);
const setEnvironments = useSetAtom(environmentsAtom);
useListenToTauriEvent<ModelPayload>('upserted_model', ({ payload }) => { useListenToTauriEvent<ModelPayload>('upserted_model', ({ payload }) => {
const { model, windowLabel } = payload; const { model, windowLabel } = payload;
const queryKey = const queryKey =
model.model === 'http_request' model.model === 'http_response'
? httpRequestsQueryKey(model)
: model.model === 'http_response'
? httpResponsesQueryKey(model) ? httpResponsesQueryKey(model)
: model.model === 'folder' : model.model === 'folder'
? foldersQueryKey(model) ? foldersQueryKey(model)
: model.model === 'environment'
? environmentsQueryKey(model)
: model.model === 'grpc_connection' : model.model === 'grpc_connection'
? grpcConnectionsQueryKey(model) ? grpcConnectionsQueryKey(model)
: model.model === 'grpc_event' : model.model === 'grpc_event'
? grpcEventsQueryKey(model) ? grpcEventsQueryKey(model)
: model.model === 'grpc_request'
? grpcRequestsQueryKey(model)
: model.model === 'workspace'
? workspacesQueryKey(model)
: model.model === 'key_value' : model.model === 'key_value'
? keyValueQueryKey(model) ? keyValueQueryKey(model)
: model.model === 'cookie_jar' : model.model === 'cookie_jar'
@@ -91,11 +89,6 @@ export function GlobalHooks() {
? settingsQueryKey() ? settingsQueryKey()
: null; : null;
if (queryKey === null) {
console.log('Unrecognized updated model:', model);
return;
}
if (model.model === 'http_request' && windowLabel !== getCurrentWebviewWindow().label) { if (model.model === 'http_request' && windowLabel !== getCurrentWebviewWindow().label) {
wasUpdatedExternally(model.id); wasUpdatedExternally(model.id);
} }
@@ -106,21 +99,27 @@ export function GlobalHooks() {
if (shouldIgnoreModel(model, windowLabel)) return; if (shouldIgnoreModel(model, windowLabel)) return;
queryClient.setQueryData(queryKey, (current: unknown) => { if (model.model === 'workspace') {
if (model.model === 'key_value') { setWorkspaces(updateModelList(model, pushToFront));
// Special-case for KeyValue } else if (model.model === 'http_request') {
return extractKeyValue(model); setHttpRequests(updateModelList(model, pushToFront));
} } else if (model.model === 'grpc_request') {
setGrpcRequests(updateModelList(model, pushToFront));
if (Array.isArray(current)) { } else if (model.model === 'environment') {
const index = current.findIndex((v) => modelsEq(v, model)) ?? -1; setEnvironments(updateModelList(model, pushToFront));
if (index >= 0) { } else if (queryKey != null) {
return [...current.slice(0, index), model, ...current.slice(index + 1)]; // TODO: Convert all models to use Jotai
} else { queryClient.setQueryData(queryKey, (current: unknown) => {
return pushToFront ? [model, ...(current ?? [])] : [...(current ?? []), model]; if (model.model === 'key_value') {
// Special-case for KeyValue
return extractKeyValue(model);
} }
}
}); if (Array.isArray(current)) {
return updateModelList(model, pushToFront)(current);
}
});
}
}); });
useListenToTauriEvent<ModelPayload>('deleted_model', ({ payload }) => { useListenToTauriEvent<ModelPayload>('deleted_model', ({ payload }) => {
@@ -128,17 +127,17 @@ export function GlobalHooks() {
if (shouldIgnoreModel(model, windowLabel)) return; if (shouldIgnoreModel(model, windowLabel)) return;
if (model.model === 'workspace') { if (model.model === 'workspace') {
queryClient.setQueryData(workspacesQueryKey(), removeById(model)); setWorkspaces(removeById(model));
} else if (model.model === 'http_request') { } else if (model.model === 'http_request') {
queryClient.setQueryData(httpRequestsQueryKey(model), removeById(model)); setHttpRequests(removeById(model));
} else if (model.model === 'http_response') { } else if (model.model === 'http_response') {
queryClient.setQueryData(httpResponsesQueryKey(model), removeById(model)); queryClient.setQueryData(httpResponsesQueryKey(model), removeById(model));
} else if (model.model === 'folder') { } else if (model.model === 'folder') {
queryClient.setQueryData(foldersQueryKey(model), removeById(model)); queryClient.setQueryData(foldersQueryKey(model), removeById(model));
} else if (model.model === 'environment') { } else if (model.model === 'environment') {
queryClient.setQueryData(environmentsQueryKey(model), removeById(model)); setEnvironments(removeById(model));
} else if (model.model === 'grpc_request') { } else if (model.model === 'grpc_request') {
queryClient.setQueryData(grpcRequestsQueryKey(model), removeById(model)); setGrpcRequests(removeById(model));
} else if (model.model === 'grpc_connection') { } else if (model.model === 'grpc_connection') {
queryClient.setQueryData(grpcConnectionsQueryKey(model), removeById(model)); queryClient.setQueryData(grpcConnectionsQueryKey(model), removeById(model));
} else if (model.model === 'grpc_event') { } else if (model.model === 'grpc_event') {
@@ -192,8 +191,19 @@ export function GlobalHooks() {
return null; return null;
} }
function updateModelList<T extends Model>(model: T, pushToFront: boolean) {
return (current: T[]): T[] => {
const index = current.findIndex((v) => modelsEq(v, model)) ?? -1;
if (index >= 0) {
return [...current.slice(0, index), model, ...current.slice(index + 1)];
} else {
return pushToFront ? [model, ...(current ?? [])] : [...(current ?? []), model];
}
};
}
function removeById<T extends { id: string }>(model: T) { function removeById<T extends { id: string }>(model: T) {
return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id); return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id) ?? [];
} }
const shouldIgnoreModel = (payload: Model, windowLabel: string) => { const shouldIgnoreModel = (payload: Model, windowLabel: string) => {

View File

@@ -7,7 +7,7 @@ interface Props {
export function IsDev({ children }: Props) { export function IsDev({ children }: Props) {
const appInfo = useAppInfo(); const appInfo = useAppInfo();
if (!appInfo?.isDev) { if (!appInfo.isDev) {
return null; return null;
} }

View File

@@ -1,13 +1,10 @@
import { useQueryClient } from '@tanstack/react-query'; import type { GrpcRequest, HttpRequest } from '@yaakapp/api';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useAppRoutes } from '../hooks/useAppRoutes'; import { useAppRoutes } from '../hooks/useAppRoutes';
import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests';
import { httpRequestsQueryKey } from '../hooks/useHttpRequests';
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest'; import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest'; import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { useWorkspaces } from '../hooks/useWorkspaces'; import { useWorkspaces } from '../hooks/useWorkspaces';
import { fallbackRequestName } from '../lib/fallbackRequestName'; import { fallbackRequestName } from '../lib/fallbackRequestName';
import type { GrpcRequest, HttpRequest } from '@yaakapp/api';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode'; import { InlineCode } from './core/InlineCode';
import { Select } from './core/Select'; import { Select } from './core/Select';
@@ -22,7 +19,6 @@ interface Props {
export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Props) { export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Props) {
const workspaces = useWorkspaces(); const workspaces = useWorkspaces();
const queryClient = useQueryClient();
const updateHttpRequest = useUpdateAnyHttpRequest(); const updateHttpRequest = useUpdateAnyHttpRequest();
const updateGrpcRequest = useUpdateAnyGrpcRequest(); const updateGrpcRequest = useUpdateAnyGrpcRequest();
const toast = useToast(); const toast = useToast();
@@ -52,14 +48,8 @@ export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Pr
if (request.model === 'http_request') { if (request.model === 'http_request') {
await updateHttpRequest.mutateAsync(args); await updateHttpRequest.mutateAsync(args);
await queryClient.invalidateQueries({
queryKey: httpRequestsQueryKey({ workspaceId: activeWorkspaceId }),
});
} else if (request.model === 'grpc_request') { } else if (request.model === 'grpc_request') {
await updateGrpcRequest.mutateAsync(args); await updateGrpcRequest.mutateAsync(args);
await queryClient.invalidateQueries({
queryKey: grpcRequestsQueryKey({ workspaceId: activeWorkspaceId }),
});
} }
// Hide after a moment, to give time for request to disappear // Hide after a moment, to give time for request to disappear

View File

@@ -117,9 +117,9 @@ export function SettingsGeneral() {
<Heading size={2}>App Info</Heading> <Heading size={2}>App Info</Heading>
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Version" value={appInfo?.version} /> <KeyValueRow label="Version" value={appInfo.version} />
<KeyValueRow label="Data Directory" value={appInfo?.appDataDir} /> <KeyValueRow label="Data Directory" value={appInfo.appDataDir} />
<KeyValueRow label="Logs Directory" value={appInfo?.appLogDir} /> <KeyValueRow label="Logs Directory" value={appInfo.appLogDir} />
</KeyValueRows> </KeyValueRows>
</VStack> </VStack>
); );

View File

@@ -73,7 +73,7 @@ export function SettingsDropdown() {
leftSlot: <Icon icon="folderOutput" />, leftSlot: <Icon icon="folderOutput" />,
onSelect: () => exportData.mutate(), onSelect: () => exportData.mutate(),
}, },
{ type: 'separator', label: `Yaak v${appInfo?.version}` }, { type: 'separator', label: `Yaak v${appInfo.version}` },
{ {
key: 'update-check', key: 'update-check',
label: 'Check for Updates', label: 'Check for Updates',
@@ -92,7 +92,7 @@ export function SettingsDropdown() {
label: 'Changelog', label: 'Changelog',
leftSlot: <Icon icon="cake" />, leftSlot: <Icon icon="cake" />,
rightSlot: <Icon icon="externalLink" />, rightSlot: <Icon icon="externalLink" />,
onSelect: () => open(`https://yaak.app/changelog/${appInfo?.version}`), onSelect: () => open(`https://yaak.app/changelog/${appInfo.version}`),
}, },
]} ]}
> >

View File

@@ -1,4 +1,4 @@
import type { Folder, GrpcRequest, HttpRequest, Workspace } from '@yaakapp/api'; import type { Folder, GrpcRequest, HttpRequest, Model, Workspace } from '@yaakapp/api';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import React, { Fragment, useCallback, useMemo, useRef, useState } from 'react'; import React, { Fragment, useCallback, useMemo, useRef, useState } from 'react';
@@ -576,7 +576,7 @@ type SidebarItemProps = {
itemId: string; itemId: string;
itemName: string; itemName: string;
itemFallbackName: string; itemFallbackName: string;
itemModel: string; itemModel: Model['model'];
itemPrefix: ReactNode; itemPrefix: ReactNode;
useProminentStyles?: boolean; useProminentStyles?: boolean;
selected: boolean; selected: boolean;
@@ -658,8 +658,10 @@ function SidebarItem({
const sendRequest = useSendAnyHttpRequest(); const sendRequest = useSendAnyHttpRequest();
const moveToWorkspace = useMoveToWorkspace(itemId); const moveToWorkspace = useMoveToWorkspace(itemId);
const sendManyRequests = useSendManyRequests(); const sendManyRequests = useSendManyRequests();
const latestHttpResponse = useLatestHttpResponse(itemId); const latestHttpResponse = useLatestHttpResponse(itemModel === 'http_request' ? itemId : null);
const latestGrpcConnection = useLatestGrpcConnection(itemId); const latestGrpcConnection = useLatestGrpcConnection(
itemModel === 'grpc_request' ? itemId : null,
);
const updateHttpRequest = useUpdateAnyHttpRequest(); const updateHttpRequest = useUpdateAnyHttpRequest();
const workspaces = useWorkspaces(); const workspaces = useWorkspaces();
const updateGrpcRequest = useUpdateAnyGrpcRequest(); const updateGrpcRequest = useUpdateAnyGrpcRequest();

View File

@@ -0,0 +1,211 @@
import { useCallback, useMemo, useState } from 'react';
import type { FnArg } from '../gen/FnArg';
import type { Tokens } from '../gen/Tokens';
import { useHttpRequests } from '../hooks/useHttpRequests';
import { useRenderTemplate } from '../hooks/useRenderTemplate';
import type {
TemplateFunction,
TemplateFunctionArg,
TemplateFunctionHttpRequestArg,
TemplateFunctionSelectArg,
TemplateFunctionTextArg,
} from '../hooks/useTemplateFunctions';
import { useTemplateTokensToString } from '../hooks/useTemplateTokensToString';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode';
import { PlainInput } from './core/PlainInput';
import { Select } from './core/Select';
import { VStack } from './core/Stacks';
const NULL_ARG = '__NULL__';
interface Props {
templateFunction: TemplateFunction;
initialTokens: Tokens;
hide: () => void;
onChange: (insert: string) => void;
}
export function TemplateFunctionDialog({ templateFunction, hide, initialTokens, onChange }: Props) {
const [argValues, setArgValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
const initialArgs =
initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn'
? initialTokens.tokens[0]?.val.args
: [];
for (const arg of templateFunction.args) {
const initialArg = initialArgs.find((a) => a.name === arg.name);
const initialArgValue =
initialArg?.value.type === 'str'
? initialArg?.value.text
: // TODO: Implement variable-based args
'__NULL__';
initial[arg.name] = initialArgValue ?? NULL_ARG;
}
return initial;
});
const setArgValue = useCallback((name: string, value: string) => {
setArgValues((v) => ({ ...v, [name]: value }));
}, []);
const tokens: Tokens = useMemo(() => {
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
name,
value:
argValues[name] === NULL_ARG
? { type: 'null' }
: {
type: 'str',
text: argValues[name] ?? '',
},
}));
return {
tokens: [
{
type: 'tag',
val: {
type: 'fn',
name: templateFunction.name,
args: argTokens,
},
},
],
};
}, [argValues, templateFunction.name]);
const tagText = useTemplateTokensToString(tokens);
const handleDone = () => {
if (tagText.data) {
onChange(tagText.data);
}
hide();
};
const rendered = useRenderTemplate(tagText.data ?? '');
return (
<VStack className="pb-3" space={4}>
<VStack space={2}>
{templateFunction.args.map((a: TemplateFunctionArg, i: number) => {
switch (a.type) {
case 'select':
return (
<SelectArg
key={i}
arg={a}
onChange={(v) => setArgValue(a.name, v)}
value={argValues[a.name] ?? '__ERROR__'}
/>
);
case 'text':
return (
<TextArg
key={i}
arg={a}
onChange={(v) => setArgValue(a.name, v)}
value={argValues[a.name] ?? '__ERROR__'}
/>
);
case 'http_request':
return (
<HttpRequestArg
key={i}
arg={a}
onChange={(v) => setArgValue(a.name, v)}
value={argValues[a.name] ?? '__ERROR__'}
/>
);
}
})}
</VStack>
<InlineCode className="select-text cursor-text">{rendered.data}</InlineCode>
<Button color="primary" onClick={handleDone}>
Done
</Button>
</VStack>
);
}
function TextArg({
arg,
onChange,
value,
}: {
arg: TemplateFunctionTextArg;
value: string;
onChange: (v: string) => void;
}) {
const handleChange = useCallback(
(value: string) => {
onChange(value === '' ? NULL_ARG : value);
},
[onChange],
);
return (
<PlainInput
name={arg.name}
onChange={handleChange}
defaultValue={value === NULL_ARG ? '' : value}
label={arg.label ?? arg.name}
hideLabel={arg.label == null}
placeholder={arg.placeholder ?? arg.defaultValue ?? ''}
/>
);
}
function SelectArg({
arg,
value,
onChange,
}: {
arg: TemplateFunctionSelectArg;
value: string;
onChange: (v: string) => void;
}) {
return (
<Select
label={arg.label ?? arg.name}
name={arg.name}
onChange={onChange}
value={value}
options={[
...arg.options.map((a) => ({
label: a.name + (arg.defaultValue === a.value ? ' (default)' : ''),
value: a.value === arg.defaultValue ? NULL_ARG : a.value,
})),
]}
/>
);
}
function HttpRequestArg({
arg,
value,
onChange,
}: {
arg: TemplateFunctionHttpRequestArg;
value: string;
onChange: (v: string) => void;
}) {
const httpRequests = useHttpRequests();
return (
<Select
label={arg.label ?? arg.name}
name={arg.name}
onChange={onChange}
value={value}
options={[
...httpRequests.map((r) => ({
label: fallbackRequestName(r),
value: r.id,
})),
]}
/>
);
}

View File

@@ -0,0 +1,69 @@
import type { EnvironmentVariable } from '@yaakapp/api';
import { useCallback, useMemo, useState } from 'react';
import type { Tokens } from '../gen/Tokens';
import { useActiveEnvironmentVariables } from '../hooks/useActiveEnvironmentVariables';
import { useRenderTemplate } from '../hooks/useRenderTemplate';
import { useTemplateTokensToString } from '../hooks/useTemplateTokensToString';
import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode';
import { Select } from './core/Select';
import { VStack } from './core/Stacks';
interface Props {
definition: EnvironmentVariable;
initialTokens: Tokens;
hide: () => void;
onChange: (rawTag: string) => void;
}
export function TemplateVariableDialog({ hide, onChange, initialTokens }: Props) {
const variables = useActiveEnvironmentVariables();
const [selectedVariableName, setSelectedVariableName] = useState<string>(() => {
return initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'var'
? initialTokens.tokens[0]?.val.name
: ''; // Should never happen
});
const tokens: Tokens = useMemo(() => {
const selectedVariable = variables.find((v) => v.name === selectedVariableName);
return {
tokens: [
{
type: 'tag',
val: {
type: 'var',
name: selectedVariable?.name ?? '',
},
},
],
};
}, [selectedVariableName, variables]);
const tagText = useTemplateTokensToString(tokens);
const handleDone = useCallback(async () => {
if (tagText.data != null) {
onChange(tagText.data);
}
hide();
}, [hide, onChange, tagText.data]);
const rendered = useRenderTemplate(tagText.data ?? '');
return (
<VStack className="pb-3" space={4}>
<VStack space={2}>
<Select
name="variable"
label="Variable"
value={selectedVariableName}
options={variables.map((v) => ({ label: v.name, value: v.name }))}
onChange={setSelectedVariableName}
/>
</VStack>
<InlineCode className="select-text cursor-text">{rendered.data}</InlineCode>
<Button color="primary" onClick={handleDone}>
Done
</Button>
</VStack>
);
}

View File

@@ -24,7 +24,7 @@
} }
.cm-placeholder { .cm-placeholder {
@apply text-text-subtlest; @apply text-placeholder;
} }
.cm-scroller { .cm-scroller {
@@ -60,12 +60,12 @@
} }
} }
.placeholder { .template-tag {
/* Colors */ /* Colors */
@apply bg-surface text-text-subtle border-border-subtle; @apply bg-surface text-text-subtle border-border-subtle;
@apply hover:border-border-subtle hover:text-text hover:bg-surface-highlight; @apply hover:border-border-subtle hover:text-text hover:bg-surface-highlight;
@apply border px-1 mx-[0.5px] rounded cursor-default dark:shadow; @apply inline border px-1 mx-[0.5px] rounded cursor-default dark:shadow;
-webkit-text-security: none; -webkit-text-security: none;
} }

View File

@@ -1,6 +1,7 @@
import { defaultKeymap } from '@codemirror/commands'; import { defaultKeymap } from '@codemirror/commands';
import { Compartment, EditorState, type Extension } from '@codemirror/state'; import { Compartment, EditorState, type Extension } from '@codemirror/state';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view'; import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import type { EnvironmentVariable } from '@yaakapp/api';
import classNames from 'classnames'; import classNames from 'classnames';
import { EditorView } from 'codemirror'; import { EditorView } from 'codemirror';
import type { MutableRefObject, ReactNode } from 'react'; import type { MutableRefObject, ReactNode } from 'react';
@@ -15,9 +16,13 @@ import {
useMemo, useMemo,
useRef, useRef,
} from 'react'; } from 'react';
import { useActiveEnvironment } from '../../../hooks/useActiveEnvironment'; import { useActiveEnvironmentVariables } from '../../../hooks/useActiveEnvironmentVariables';
import { useActiveWorkspace } from '../../../hooks/useActiveWorkspace'; import { parseTemplate } from '../../../hooks/useParseTemplate';
import { useSettings } from '../../../hooks/useSettings'; import { useSettings } from '../../../hooks/useSettings';
import { type TemplateFunction, useTemplateFunctions } from '../../../hooks/useTemplateFunctions';
import { useDialog } from '../../DialogContext';
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
import { TemplateVariableDialog } from '../../TemplateVariableDialog';
import { IconButton } from '../IconButton'; import { IconButton } from '../IconButton';
import { HStack } from '../Stacks'; import { HStack } from '../Stacks';
import './Editor.css'; import './Editor.css';
@@ -58,6 +63,8 @@ export interface EditorProps {
actions?: ReactNode; actions?: ReactNode;
} }
const emptyVariables: EnvironmentVariable[] = [];
export const Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor( export const Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
{ {
readOnly, readOnly,
@@ -87,10 +94,9 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
ref, ref,
) { ) {
const s = useSettings(); const s = useSettings();
const [e] = useActiveEnvironment(); const templateFunctions = useTemplateFunctions();
const w = useActiveWorkspace(); const allEnvironmentVariables = useActiveEnvironmentVariables();
const environment = autocompleteVariables ? e : null; const environmentVariables = autocompleteVariables ? allEnvironmentVariables : emptyVariables;
const workspace = autocompleteVariables ? w : null;
if (s && wrapLines === undefined) { if (s && wrapLines === undefined) {
wrapLines = s.editorSoftWrap; wrapLines = s.editorSoftWrap;
@@ -148,19 +154,78 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
cm.current?.view.dispatch({ effects: effect }); cm.current?.view.dispatch({ effects: effect });
}, [wrapLines]); }, [wrapLines]);
const dialog = useDialog();
const onClickFunction = useCallback(
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const initialTokens = await parseTemplate(tagValue);
dialog.show({
id: 'template-function',
size: 'sm',
title: 'Configure Function',
render: ({ hide }) => (
<TemplateFunctionDialog
templateFunction={fn}
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
cm.current?.view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
),
});
},
[dialog],
);
const onClickVariable = useCallback(
async (v: EnvironmentVariable, tagValue: string, startPos: number) => {
const initialTokens = await parseTemplate(tagValue);
dialog.show({
size: 'dynamic',
id: 'template-variable',
title: 'Configure Variable',
render: ({ hide }) => (
<TemplateVariableDialog
definition={v}
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
cm.current?.view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
),
});
},
[dialog],
);
// Update language extension when contentType changes // Update language extension when contentType changes
useEffect(() => { useEffect(() => {
if (cm.current === null) return; if (cm.current === null) return;
const { view, languageCompartment } = cm.current; const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({ const ext = getLanguageExtension({
contentType, contentType,
environment, environmentVariables,
workspace,
useTemplating, useTemplating,
autocomplete, autocomplete,
templateFunctions,
onClickFunction,
onClickVariable,
}); });
view.dispatch({ effects: languageCompartment.reconfigure(ext) }); view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [contentType, autocomplete, useTemplating, environment, workspace]); }, [
contentType,
autocomplete,
useTemplating,
environmentVariables,
templateFunctions,
onClickFunction,
onClickVariable,
]);
// Initialize the editor when ref mounts // Initialize the editor when ref mounts
const initEditorRef = useCallback( const initEditorRef = useCallback(
@@ -178,8 +243,10 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
contentType, contentType,
useTemplating, useTemplating,
autocomplete, autocomplete,
environment, environmentVariables,
workspace, templateFunctions,
onClickVariable,
onClickFunction,
}); });
const state = EditorState.create({ const state = EditorState.create({

View File

@@ -31,9 +31,10 @@ import {
rectangularSelection, rectangularSelection,
} from '@codemirror/view'; } from '@codemirror/view';
import { tags as t } from '@lezer/highlight'; import { tags as t } from '@lezer/highlight';
import type { EnvironmentVariable } from '@yaakapp/api';
import { graphql, graphqlLanguageSupport } from 'cm6-graphql'; import { graphql, graphqlLanguageSupport } from 'cm6-graphql';
import { EditorView } from 'codemirror'; import { EditorView } from 'codemirror';
import type { Environment, Workspace } from '@yaakapp/api'; import type { TemplateFunction } from '../../../hooks/useTemplateFunctions';
import type { EditorProps } from './index'; import type { EditorProps } from './index';
import { pairs } from './pairs/extension'; import { pairs } from './pairs/extension';
import { text } from './text/extension'; import { text } from './text/extension';
@@ -78,13 +79,17 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
export function getLanguageExtension({ export function getLanguageExtension({
contentType, contentType,
useTemplating = false, useTemplating = false,
environment, environmentVariables,
workspace,
autocomplete, autocomplete,
}: { environment: Environment | null; workspace: Workspace | null } & Pick< templateFunctions,
EditorProps, onClickVariable,
'contentType' | 'useTemplating' | 'autocomplete' onClickFunction,
>) { }: {
environmentVariables: EnvironmentVariable[];
templateFunctions: TemplateFunction[];
onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void;
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void;
} & Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) {
const justContentType = contentType?.split(';')[0] ?? contentType ?? ''; const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
if (justContentType === 'application/graphql') { if (justContentType === 'application/graphql') {
return graphql(); return graphql();
@@ -94,7 +99,14 @@ export function getLanguageExtension({
return base; return base;
} }
return twig(base, environment, workspace, autocomplete); return twig({
base,
environmentVariables,
templateFunctions,
autocomplete,
onClickFunction,
onClickVariable,
});
} }
export const baseExtensions = [ export const baseExtensions = [

View File

@@ -75,21 +75,21 @@ const decorator = function () {
return ViewPlugin.fromClass( return ViewPlugin.fromClass(
class { class {
placeholders: DecorationSet; decorations: DecorationSet;
constructor(view: EditorView) { constructor(view: EditorView) {
this.placeholders = placeholderMatcher.createDeco(view); this.decorations = placeholderMatcher.createDeco(view);
} }
update(update: ViewUpdate) { update(update: ViewUpdate) {
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders); this.decorations = placeholderMatcher.updateDeco(update, this.decorations);
} }
}, },
{ {
decorations: (instance) => instance.placeholders, decorations: (instance) => instance.decorations,
provide: (plugin) => provide: (plugin) =>
EditorView.bidiIsolatedRanges.of((view) => { EditorView.bidiIsolatedRanges.of((view) => {
return view.plugin(plugin)?.placeholders || Decoration.none; return view.plugin(plugin)?.decorations || Decoration.none;
}), }),
}, },
); );

View File

@@ -3,8 +3,12 @@ import type { CompletionContext } from '@codemirror/autocomplete';
const openTag = '${[ '; const openTag = '${[ ';
const closeTag = ' ]}'; const closeTag = ' ]}';
interface TwigCompletionOption { export interface TwigCompletionOption {
name: string; name: string;
label: string;
type: 'function' | 'variable' | 'unknown';
value: string | null;
onClick?: (rawTag: string, startPos: number) => void;
} }
export interface TwigCompletionConfig { export interface TwigCompletionConfig {
@@ -41,12 +45,16 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
from: toMatch.from, from: toMatch.from,
options: options options: options
.filter((v) => v.name.trim()) .filter((v) => v.name.trim())
.map((v) => ({ .map((v) => {
label: toStartOfVariable ? `${openTag}${v.name}${closeTag}` : v.name, const innerLabel = v.type === 'function' ? `${v.name}()` : v.name;
apply: `${openTag}${v.name}${closeTag}`, const tagSyntax = openTag + innerLabel + closeTag;
type: 'variable', return {
matchLen: matchLen, label: innerLabel,
})) apply: tagSyntax,
type: v.type === 'variable' ? 'variable' : 'function',
matchLen: matchLen,
};
})
// Filter out exact matches // Filter out exact matches
.filter((o) => o.label !== toMatch.text), .filter((o) => o.label !== toMatch.text),
}; };

View File

@@ -1,29 +1,57 @@
import type { LanguageSupport } from '@codemirror/language'; import type { LanguageSupport } from '@codemirror/language';
import { LRLanguage } from '@codemirror/language'; import { LRLanguage } from '@codemirror/language';
import { parseMixed } from '@lezer/common'; import { parseMixed } from '@lezer/common';
import type { Environment, Workspace } from '@yaakapp/api'; import type { EnvironmentVariable } from '@yaakapp/api';
import type { TemplateFunction } from '../../../../hooks/useTemplateFunctions';
import type { GenericCompletionConfig } from '../genericCompletion'; import type { GenericCompletionConfig } from '../genericCompletion';
import { genericCompletion } from '../genericCompletion'; import { genericCompletion } from '../genericCompletion';
import { textLanguageName } from '../text/extension'; import { textLanguageName } from '../text/extension';
import type { TwigCompletionOption } from './completion';
import { twigCompletion } from './completion'; import { twigCompletion } from './completion';
import { placeholders } from './placeholder'; import { templateTags } from './templateTags';
import { parser as twigParser } from './twig'; import { parser as twigParser } from './twig';
export function twig( export function twig({
base: LanguageSupport, base,
environment: Environment | null, environmentVariables,
workspace: Workspace | null, templateFunctions,
autocomplete?: GenericCompletionConfig, autocomplete,
) { onClickFunction,
onClickVariable,
}: {
base: LanguageSupport;
environmentVariables: EnvironmentVariable[];
templateFunctions: TemplateFunction[];
autocomplete?: GenericCompletionConfig;
onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void;
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void;
}) {
const language = mixLanguage(base); const language = mixLanguage(base);
const allVariables = [...(workspace?.variables ?? []), ...(environment?.variables ?? [])];
const variables = allVariables.filter((v) => v.enabled) ?? []; const variableOptions: TwigCompletionOption[] =
const completions = twigCompletion({ options: variables }); environmentVariables.map((v) => ({
...v,
type: 'variable',
label: v.name,
onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos),
})) ?? [];
const functionOptions: TwigCompletionOption[] =
templateFunctions.map((fn) => ({
name: fn.name,
type: 'function',
value: null,
label: fn.name + '(' + fn.args.length + ')',
onClick: (rawTag: string, startPos: number) => onClickFunction(fn, rawTag, startPos),
})) ?? [];
const options = [...variableOptions, ...functionOptions];
const completions = twigCompletion({ options });
return [ return [
language, language,
base.support, base.support,
placeholders(variables), templateTags(options),
language.data.of({ autocomplete: completions }), language.data.of({ autocomplete: completions }),
base.language.data.of({ autocomplete: completions }), base.language.data.of({ autocomplete: completions }),
language.data.of({ autocomplete: genericCompletion(autocomplete) }), language.data.of({ autocomplete: genericCompletion(autocomplete) }),

View File

@@ -1,91 +0,0 @@
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
import { BetterMatchDecorator } from '../BetterMatchDecorator';
class PlaceholderWidget extends WidgetType {
constructor(
readonly name: string,
readonly value: string,
readonly exists: boolean,
readonly type: 'function' | 'variable' = 'variable',
) {
super();
}
eq(other: PlaceholderWidget) {
return this.name == other.name && this.exists == other.exists;
}
toDOM() {
const elt = document.createElement('span');
elt.className = `x-theme-placeholder placeholder ${
!this.exists
? 'x-theme-placeholder--danger'
: this.type === 'variable'
? 'x-theme-placeholder--primary'
: 'x-theme-placeholder--info'
}`;
elt.title = !this.exists ? 'Variable not found in active environment' : this.value ?? '';
elt.textContent = this.name;
return elt;
}
ignoreEvent() {
return false;
}
}
export const placeholders = function (variables: { name: string; value?: string }[]) {
const placeholderMatcher = new BetterMatchDecorator({
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,
decoration(match, view, matchStartPos) {
const matchEndPos = matchStartPos + match[0].length - 1;
// Don't decorate if the cursor is inside the match
for (const r of view.state.selection.ranges) {
if (r.from > matchStartPos && r.to <= matchEndPos) {
return Decoration.replace({});
}
}
const groupMatch = match[1];
if (groupMatch == null) {
// Should never happen, but make TS happy
console.warn('Group match was empty', match);
return Decoration.replace({});
}
const isFunction = groupMatch.includes('(');
return Decoration.replace({
inclusive: true,
widget: new PlaceholderWidget(
groupMatch,
variables.find((v) => v.name === groupMatch)?.value ?? '',
isFunction ? true : variables.some((v) => v.name === groupMatch),
isFunction ? 'function' : 'variable',
),
});
},
});
return ViewPlugin.fromClass(
class {
placeholders: DecorationSet;
constructor(view: EditorView) {
this.placeholders = placeholderMatcher.createDeco(view);
}
update(update: ViewUpdate) {
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
}
},
{
decorations: (instance) => instance.placeholders,
provide: (plugin) =>
EditorView.atomicRanges.of((view) => {
return view.plugin(plugin)?.placeholders || Decoration.none;
}),
},
);
};

View File

@@ -0,0 +1,115 @@
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
import { truncate } from '../../../../lib/truncate';
import { BetterMatchDecorator } from '../BetterMatchDecorator';
import type { TwigCompletionOption } from './completion';
const TAG_TRUNCATE_LEN = 30;
class TemplateTagWidget extends WidgetType {
readonly #clickListenerCallback: () => void;
constructor(
readonly option: TwigCompletionOption,
readonly rawTag: string,
readonly startPos: number,
) {
super();
this.#clickListenerCallback = () => {
this.option.onClick?.(this.rawTag, this.startPos);
};
}
eq(other: TemplateTagWidget) {
return (
this.option.name === other.option.name &&
this.option.type === other.option.type &&
this.option.value === other.option.value &&
this.rawTag === other.rawTag
);
}
toDOM() {
const elt = document.createElement('span');
elt.className = `x-theme-templateTag template-tag ${
this.option.type === 'unknown'
? 'x-theme-templateTag--danger'
: this.option.type === 'variable'
? 'x-theme-templateTag--primary'
: 'x-theme-templateTag--info'
}`;
elt.title = this.option.type === 'unknown' ? '__NOT_FOUND__' : this.option.value ?? '';
elt.textContent = truncate(
this.rawTag.replace('${[', '').replace(']}', '').trim(),
TAG_TRUNCATE_LEN,
);
elt.addEventListener('click', this.#clickListenerCallback);
return elt;
}
destroy(dom: HTMLElement) {
dom.removeEventListener('click', this.#clickListenerCallback);
super.destroy(dom);
}
ignoreEvent() {
return false;
}
}
export function templateTags(options: TwigCompletionOption[]) {
const templateTagMatcher = new BetterMatchDecorator({
regexp: /\$\{\[\s*([^\]]+)\s*]}/g,
decoration(match, view, matchStartPos) {
const matchEndPos = matchStartPos + match[0].length - 1;
// Don't decorate if the cursor is inside the match
for (const r of view.state.selection.ranges) {
if (r.from > matchStartPos && r.to <= matchEndPos) {
return Decoration.replace({});
}
}
const innerTagMatch = match[1];
if (innerTagMatch == null) {
// Should never happen, but make TS happy
console.warn('Group match was empty', match);
return Decoration.replace({});
}
// TODO: Replace this hacky match with a proper template parser
const name = innerTagMatch.match(/\s*(\w+)[(\s]*/)?.[1] ?? innerTagMatch;
let option = options.find((v) => v.name === name);
if (option == null) {
option = { type: 'unknown', name: innerTagMatch, value: null, label: innerTagMatch };
}
return Decoration.replace({
inclusive: true,
widget: new TemplateTagWidget(option, match[0], matchStartPos),
});
},
});
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = templateTagMatcher.createDeco(view);
}
update(update: ViewUpdate) {
this.decorations = templateTagMatcher.updateDeco(update, this.decorations);
}
},
{
decorations: (instance) => instance.decorations,
provide: (plugin) =>
EditorView.atomicRanges.of((view) => {
return view.plugin(plugin)?.decorations || Decoration.none;
}),
},
);
}

View File

@@ -84,7 +84,7 @@ export function Select<T extends string>({
{options.map((o) => { {options.map((o) => {
if (o.type === 'separator') return null; if (o.type === 'separator') return null;
return ( return (
<option key={o.label} value={o.value}> <option key={o.value} value={o.value}>
{o.label} {o.label}
</option> </option>
); );

4
src-web/gen/FnArg.ts Normal file
View File

@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Val } from "./Val";
export type FnArg = { name: string, value: Val, };

4
src-web/gen/Token.ts Normal file
View File

@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Val } from "./Val";
export type Token = { "type": "raw", text: string, } | { "type": "tag", val: Val, } | { "type": "eof" };

4
src-web/gen/Tokens.ts Normal file
View File

@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Token } from "./Token";
export type Tokens = { tokens: Array<Token>, };

4
src-web/gen/Val.ts Normal file
View File

@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FnArg } from "./FnArg";
export type Val = { "type": "str", text: string, } | { "type": "var", name: string, } | { "type": "fn", name: string, args: Array<FnArg>, } | { "type": "null" };

View File

@@ -14,7 +14,7 @@ export function useActiveEnvironment() {
export const QUERY_ENVIRONMENT_ID = 'environment_id'; export const QUERY_ENVIRONMENT_ID = 'environment_id';
export function useActiveEnvironmentId() { function useActiveEnvironmentId() {
// NOTE: This query param is accessed from Rust side, so do not change // NOTE: This query param is accessed from Rust side, so do not change
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const id = params.get(QUERY_ENVIRONMENT_ID); const id = params.get(QUERY_ENVIRONMENT_ID);

View File

@@ -0,0 +1,24 @@
import type { EnvironmentVariable } from '@yaakapp/api';
import { useMemo } from 'react';
import { useActiveEnvironment } from './useActiveEnvironment';
import { useActiveWorkspace } from './useActiveWorkspace';
export function useActiveEnvironmentVariables() {
const workspace = useActiveWorkspace();
const [environment] = useActiveEnvironment();
const variables = useMemo(() => {
const varMap: Record<string, EnvironmentVariable> = {};
const allVariables = [...(workspace?.variables ?? []), ...(environment?.variables ?? [])];
for (const v of allVariables) {
if (!v.enabled || !v.name) continue;
varMap[v.name] = v;
}
return Object.values(varMap);
}, [workspace, environment]);
return variables;
}

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import type { Workspace } from '@yaakapp/api'; import type { Workspace } from '@yaakapp/api';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import type { RouteParamsWorkspace } from './useAppRoutes'; import type { RouteParamsWorkspace } from './useAppRoutes';
import { useWorkspaces } from './useWorkspaces'; import { useWorkspaces } from './useWorkspaces';
@@ -7,6 +7,7 @@ import { useWorkspaces } from './useWorkspaces';
export function useActiveWorkspace(): Workspace | null { export function useActiveWorkspace(): Workspace | null {
const workspaceId = useActiveWorkspaceId(); const workspaceId = useActiveWorkspaceId();
const workspaces = useWorkspaces(); const workspaces = useWorkspaces();
return useMemo( return useMemo(
() => workspaces.find((w) => w.id === workspaceId) ?? null, () => workspaces.find((w) => w.id === workspaceId) ?? null,
[workspaces, workspaceId], [workspaces, workspaceId],

View File

@@ -1,4 +1,3 @@
import { useQuery } from '@tanstack/react-query';
import { invokeCmd } from '../lib/tauri'; import { invokeCmd } from '../lib/tauri';
export interface AppInfo { export interface AppInfo {
@@ -9,12 +8,8 @@ export interface AppInfo {
appLogDir: string; appLogDir: string;
} }
const appInfo = (await invokeCmd('cmd_metadata')) as AppInfo;
export function useAppInfo() { export function useAppInfo() {
return useQuery({ return appInfo;
queryKey: ['appInfo'],
queryFn: async () => {
const metadata = await invokeCmd('cmd_metadata');
return metadata as AppInfo;
},
}).data;
} }

View File

@@ -18,7 +18,7 @@ export function useCheckForUpdates() {
title: 'No Update Available', title: 'No Update Available',
body: ( body: (
<> <>
You are currently on the latest version <InlineCode>{appInfo?.version}</InlineCode> You are currently on the latest version <InlineCode>{appInfo.version}</InlineCode>
</> </>
), ),
}); });

View File

@@ -1,12 +1,11 @@
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import type { GrpcRequest } from '@yaakapp/api'; import type { GrpcRequest } from '@yaakapp/api';
import { trackEvent } from '../lib/analytics'; import { trackEvent } from '../lib/analytics';
import { setKeyValue } from '../lib/keyValueStore';
import { invokeCmd } from '../lib/tauri'; import { invokeCmd } from '../lib/tauri';
import { useActiveEnvironment } from './useActiveEnvironment'; import { useActiveEnvironment } from './useActiveEnvironment';
import { useActiveWorkspace } from './useActiveWorkspace'; import { useActiveWorkspace } from './useActiveWorkspace';
import { useAppRoutes } from './useAppRoutes'; import { useAppRoutes } from './useAppRoutes';
import { protoFilesArgs, useGrpcProtoFiles } from './useGrpcProtoFiles'; import { getGrpcProtoFiles, setGrpcProtoFiles } from './useGrpcProtoFiles';
export function useDuplicateGrpcRequest({ export function useDuplicateGrpcRequest({
id, id,
@@ -18,7 +17,7 @@ export function useDuplicateGrpcRequest({
const activeWorkspace = useActiveWorkspace(); const activeWorkspace = useActiveWorkspace();
const [activeEnvironment] = useActiveEnvironment(); const [activeEnvironment] = useActiveEnvironment();
const routes = useAppRoutes(); const routes = useAppRoutes();
const protoFiles = useGrpcProtoFiles(id);
return useMutation<GrpcRequest, string>({ return useMutation<GrpcRequest, string>({
mutationKey: ['duplicate_grpc_request', id], mutationKey: ['duplicate_grpc_request', id],
mutationFn: async () => { mutationFn: async () => {
@@ -27,8 +26,11 @@ export function useDuplicateGrpcRequest({
}, },
onSettled: () => trackEvent('grpc_request', 'duplicate'), onSettled: () => trackEvent('grpc_request', 'duplicate'),
onSuccess: async (request) => { onSuccess: async (request) => {
if (id == null) return;
// Also copy proto files to new request // Also copy proto files to new request
await setKeyValue({ ...protoFilesArgs(request.id), value: protoFiles.value ?? [] }); const protoFiles = await getGrpcProtoFiles(id);
await setGrpcProtoFiles(request.id, protoFiles);
if (navigateAfter && activeWorkspace !== null) { if (navigateAfter && activeWorkspace !== null) {
routes.navigate('request', { routes.navigate('request', {

View File

@@ -1,24 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import type { Environment } from '@yaakapp/api'; import type { Environment } from '@yaakapp/api';
import { atom, useAtom } from 'jotai/index';
import { useEffect } from 'react';
import { invokeCmd } from '../lib/tauri'; import { invokeCmd } from '../lib/tauri';
import { useActiveWorkspace } from './useActiveWorkspace'; import { useActiveWorkspace } from './useActiveWorkspace';
export function environmentsQueryKey({ workspaceId }: { workspaceId: string }) { export const environmentsAtom = atom<Environment[]>([]);
return ['environments', { workspaceId }];
}
export function useEnvironments() { export function useEnvironments() {
const [items, setItems] = useAtom(environmentsAtom);
const workspace = useActiveWorkspace(); const workspace = useActiveWorkspace();
return (
useQuery({ // Fetch new requests when workspace changes
enabled: workspace != null, useEffect(() => {
queryKey: environmentsQueryKey({ workspaceId: workspace?.id ?? 'n/a' }), if (workspace == null) return;
queryFn: async () => { invokeCmd<Environment[]>('cmd_list_environments', { workspaceId: workspace.id }).then(setItems);
if (workspace == null) return []; }, [setItems, workspace]);
return (await invokeCmd('cmd_list_environments', {
workspaceId: workspace.id, return items;
})) as Environment[];
},
}).data ?? []
);
} }

View File

@@ -13,6 +13,7 @@ export function useGrpcConnections(requestId: string | null) {
initialData: [], initialData: [],
queryKey: grpcConnectionsQueryKey({ requestId: requestId ?? 'n/a' }), queryKey: grpcConnectionsQueryKey({ requestId: requestId ?? 'n/a' }),
queryFn: async () => { queryFn: async () => {
if (requestId == null) return [];
return (await invokeCmd('cmd_list_grpc_connections', { return (await invokeCmd('cmd_list_grpc_connections', {
requestId, requestId,
limit: 200, limit: 200,

View File

@@ -1,3 +1,4 @@
import { getKeyValue, setKeyValue } from '../lib/keyValueStore';
import { useKeyValue } from './useKeyValue'; import { useKeyValue } from './useKeyValue';
export function protoFilesArgs(requestId: string | null) { export function protoFilesArgs(requestId: string | null) {
@@ -10,3 +11,11 @@ export function protoFilesArgs(requestId: string | null) {
export function useGrpcProtoFiles(activeRequestId: string | null) { export function useGrpcProtoFiles(activeRequestId: string | null) {
return useKeyValue<string[]>({ ...protoFilesArgs(activeRequestId), fallback: [] }); return useKeyValue<string[]>({ ...protoFilesArgs(activeRequestId), fallback: [] });
} }
export async function getGrpcProtoFiles(requestId: string) {
return getKeyValue<string[]>({ ...protoFilesArgs(requestId), fallback: [] });
}
export async function setGrpcProtoFiles(requestId: string, protoFiles: string[]) {
return setKeyValue<string[]>({ ...protoFilesArgs(requestId), value: protoFiles });
}

View File

@@ -1,24 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import type { GrpcRequest } from '@yaakapp/api'; import type { GrpcRequest } from '@yaakapp/api';
import { atom, useAtom } from 'jotai';
import { useEffect } from 'react';
import { invokeCmd } from '../lib/tauri'; import { invokeCmd } from '../lib/tauri';
import { useActiveWorkspace } from './useActiveWorkspace'; import { useActiveWorkspace } from './useActiveWorkspace';
export function grpcRequestsQueryKey({ workspaceId }: { workspaceId: string }) { export const grpcRequestsAtom = atom<GrpcRequest[]>([]);
return ['grpc_requests', { workspaceId }];
}
export function useGrpcRequests() { export function useGrpcRequests() {
const [items, setItems] = useAtom(grpcRequestsAtom);
const workspace = useActiveWorkspace(); const workspace = useActiveWorkspace();
return (
useQuery({ // Fetch new requests when workspace changes
enabled: workspace != null, useEffect(() => {
queryKey: grpcRequestsQueryKey({ workspaceId: workspace?.id ?? 'n/a' }), if (workspace == null) return;
queryFn: async () => { invokeCmd<GrpcRequest[]>('cmd_list_grpc_requests', { workspaceId: workspace.id }).then(
if (workspace == null) return []; setItems,
return (await invokeCmd('cmd_list_grpc_requests', { );
workspaceId: workspace.id, }, [setItems, workspace]);
})) as GrpcRequest[];
}, return items;
}).data ?? []
);
} }

View File

@@ -9,6 +9,7 @@ import { invokeCmd } from '../lib/tauri';
export function useHttpRequestActions() { export function useHttpRequestActions() {
const httpRequestActions = useQuery({ const httpRequestActions = useQuery({
queryKey: ['http_request_actions'], queryKey: ['http_request_actions'],
refetchOnWindowFocus: false,
queryFn: async () => { queryFn: async () => {
const responses = (await invokeCmd( const responses = (await invokeCmd(
'cmd_http_request_actions', 'cmd_http_request_actions',

View File

@@ -1,24 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import type { HttpRequest } from '@yaakapp/api'; import type { HttpRequest } from '@yaakapp/api';
import { atom, useAtom } from 'jotai';
import { useEffect } from 'react';
import { invokeCmd } from '../lib/tauri'; import { invokeCmd } from '../lib/tauri';
import { useActiveWorkspace } from './useActiveWorkspace'; import { useActiveWorkspace } from './useActiveWorkspace';
export function httpRequestsQueryKey({ workspaceId }: { workspaceId: string }) { export const httpRequestsAtom = atom<HttpRequest[]>([]);
return ['http_requests', { workspaceId }];
}
export function useHttpRequests() { export function useHttpRequests() {
const [items, setItems] = useAtom(httpRequestsAtom);
const workspace = useActiveWorkspace(); const workspace = useActiveWorkspace();
return (
useQuery({ useEffect(() => {
enabled: workspace != null, if (workspace == null) return;
queryKey: httpRequestsQueryKey({ workspaceId: workspace?.id ?? 'n/a' }), invokeCmd<HttpRequest[]>('cmd_list_http_requests', { workspaceId: workspace.id }).then(
queryFn: async () => { setItems,
if (workspace == null) return []; );
return (await invokeCmd('cmd_list_http_requests', { }, [setItems, workspace]);
workspaceId: workspace.id,
})) as HttpRequest[]; return items;
},
}).data ?? []
);
} }

View File

@@ -13,6 +13,7 @@ export function useHttpResponses(requestId: string | null) {
initialData: [], initialData: [],
queryKey: httpResponsesQueryKey({ requestId: requestId ?? 'n/a' }), queryKey: httpResponsesQueryKey({ requestId: requestId ?? 'n/a' }),
queryFn: async () => { queryFn: async () => {
if (requestId == null) return [];
return (await invokeCmd('cmd_list_http_responses', { return (await invokeCmd('cmd_list_http_responses', {
requestId, requestId,
limit: 200, limit: 200,

View File

@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import type { Tokens } from '../gen/Tokens';
import { invokeCmd } from '../lib/tauri';
export function useParseTemplate(template: string) {
return useQuery<Tokens>({
queryKey: ['parse_template', template],
queryFn: () => parseTemplate(template),
});
}
export async function parseTemplate(template: string): Promise<Tokens> {
return invokeCmd('cmd_parse_template', { template });
}

View File

@@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import { invokeCmd } from '../lib/tauri';
import { useActiveEnvironment } from './useActiveEnvironment';
import { useActiveWorkspace } from './useActiveWorkspace';
export function useRenderTemplate(template: string) {
const workspaceId = useActiveWorkspace()?.id ?? 'n/a';
const environmentId = useActiveEnvironment()[0]?.id ?? null;
return useQuery<string>({
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: ['render_template', template],
queryFn: () => renderTemplate({ template, workspaceId, environmentId }),
});
}
export async function renderTemplate({
template,
workspaceId,
environmentId,
}: {
template: string;
workspaceId: string;
environmentId: string | null;
}): Promise<string> {
return invokeCmd('cmd_render_template', { template, workspaceId, environmentId });
}

View File

@@ -29,7 +29,7 @@ export function useSyncWorkspaceRequestTitle() {
newTitle += ` ${fallbackRequestName(activeRequest)}`; newTitle += ` ${fallbackRequestName(activeRequest)}`;
} }
if (appInfo?.isDev) { if (appInfo.isDev) {
newTitle = `[DEV] ${newTitle}`; newTitle = `[DEV] ${newTitle}`;
} }
@@ -40,5 +40,5 @@ export function useSyncWorkspaceRequestTitle() {
} else { } else {
emit('yaak_title_changed', newTitle).catch(console.error); emit('yaak_title_changed', newTitle).catch(console.error);
} }
}, [activeEnvironment, activeRequest, activeWorkspace, appInfo?.isDev, osInfo.osType]); }, [activeEnvironment, activeRequest, activeWorkspace, appInfo.isDev, osInfo.osType]);
} }

View File

@@ -0,0 +1,88 @@
import type { HttpRequest } from '@yaakapp/api';
export interface TemplateFunctionArgBase {
name: string;
optional?: boolean;
label?: string;
}
export interface TemplateFunctionSelectArg extends TemplateFunctionArgBase {
type: 'select';
defaultValue?: string;
options: readonly { name: string; value: string }[];
}
export interface TemplateFunctionTextArg extends TemplateFunctionArgBase {
type: 'text';
defaultValue?: string;
placeholder?: string;
}
export interface TemplateFunctionHttpRequestArg extends TemplateFunctionArgBase {
type: HttpRequest['model'];
}
export type TemplateFunctionArg =
| TemplateFunctionSelectArg
| TemplateFunctionTextArg
| TemplateFunctionHttpRequestArg;
export interface TemplateFunction {
name: string;
args: TemplateFunctionArg[];
}
export function useTemplateFunctions() {
const fns: TemplateFunction[] = [
{
name: 'timestamp',
args: [
{
type: 'text',
name: 'from',
label: 'From',
placeholder: '2023-23-12T04:03:03',
optional: true,
},
{
type: 'select',
label: 'Format',
name: 'format',
options: [
{ name: 'RFC3339', value: 'rfc3339' },
{ name: 'Unix', value: 'unix' },
{ name: 'Unix (ms)', value: 'unix_millis' },
],
optional: true,
defaultValue: 'RFC3339',
},
],
},
{
name: 'response',
args: [
{
type: 'http_request',
name: 'request',
label: 'Request',
},
{
type: 'select',
name: 'attribute',
label: 'Attribute',
options: [
{ name: 'Body', value: 'body' },
{ name: 'Header', value: 'header' },
],
},
{
type: 'text',
name: 'filter',
label: 'Filter',
placeholder: 'JSONPath or XPath expression',
},
],
},
];
return fns;
}

View File

@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import type { Tokens } from '../gen/Tokens';
import { invokeCmd } from '../lib/tauri';
export function useTemplateTokensToString(tokens: Tokens) {
return useQuery<string>({
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: ['template_tokens_to_string', tokens],
queryFn: () => templateTokensToString(tokens),
});
}
export async function templateTokensToString(tokens: Tokens): Promise<string> {
return invokeCmd('cmd_template_tokens_to_string', { tokens });
}

View File

@@ -1,20 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import type { Workspace } from '@yaakapp/api'; import type { Workspace } from '@yaakapp/api';
import { invokeCmd } from '../lib/tauri'; import { atom, useAtomValue } from 'jotai';
import { listWorkspaces } from '../lib/store';
// eslint-disable-next-line @typescript-eslint/no-unused-vars,@typescript-eslint/ban-types const workspaces = await listWorkspaces();
export function workspacesQueryKey(_?: {}) { export const workspacesAtom = atom<Workspace[]>(workspaces);
return ['workspaces'];
}
export function useWorkspaces() { export function useWorkspaces() {
return ( return useAtomValue(workspacesAtom);
useQuery({
queryKey: workspacesQueryKey(),
queryFn: async () => {
const workspaces = await invokeCmd('cmd_list_workspaces');
return workspaces as Workspace[];
},
}).data ?? []
);
} }

View File

@@ -52,6 +52,11 @@ export async function getWorkspace(id: string | null): Promise<Workspace | null>
return workspace; return workspace;
} }
export async function listWorkspaces(): Promise<Workspace[]> {
const workspaces: Workspace[] = (await invokeCmd('cmd_list_workspaces')) ?? [];
return workspaces;
}
export async function getCookieJar(id: string | null): Promise<CookieJar | null> { export async function getCookieJar(id: string | null): Promise<CookieJar | null> {
if (id === null) return null; if (id === null) return null;
const cookieJar: CookieJar = (await invokeCmd('cmd_get_cookie_jar', { id })) ?? null; const cookieJar: CookieJar = (await invokeCmd('cmd_get_cookie_jar', { id })) ?? null;

View File

@@ -6,6 +6,7 @@ type TauriCmd =
| 'cmd_check_for_updates' | 'cmd_check_for_updates'
| 'cmd_create_cookie_jar' | 'cmd_create_cookie_jar'
| 'cmd_create_environment' | 'cmd_create_environment'
| 'cmd_template_tokens_to_string'
| 'cmd_create_folder' | 'cmd_create_folder'
| 'cmd_create_grpc_request' | 'cmd_create_grpc_request'
| 'cmd_create_http_request' | 'cmd_create_http_request'
@@ -21,6 +22,7 @@ type TauriCmd =
| 'cmd_delete_http_request' | 'cmd_delete_http_request'
| 'cmd_delete_http_response' | 'cmd_delete_http_response'
| 'cmd_delete_workspace' | 'cmd_delete_workspace'
| 'cmd_dismiss_notification'
| 'cmd_duplicate_grpc_request' | 'cmd_duplicate_grpc_request'
| 'cmd_duplicate_http_request' | 'cmd_duplicate_http_request'
| 'cmd_export_data' | 'cmd_export_data'
@@ -35,6 +37,7 @@ type TauriCmd =
| 'cmd_get_workspace' | 'cmd_get_workspace'
| 'cmd_grpc_go' | 'cmd_grpc_go'
| 'cmd_grpc_reflect' | 'cmd_grpc_reflect'
| 'cmd_http_request_actions'
| 'cmd_import_data' | 'cmd_import_data'
| 'cmd_list_cookie_jars' | 'cmd_list_cookie_jars'
| 'cmd_list_environments' | 'cmd_list_environments'
@@ -48,7 +51,8 @@ type TauriCmd =
| 'cmd_metadata' | 'cmd_metadata'
| 'cmd_new_nested_window' | 'cmd_new_nested_window'
| 'cmd_new_window' | 'cmd_new_window'
| 'cmd_dismiss_notification' | 'cmd_parse_template'
| 'cmd_render_template'
| 'cmd_save_response' | 'cmd_save_response'
| 'cmd_send_ephemeral_request' | 'cmd_send_ephemeral_request'
| 'cmd_send_http_request' | 'cmd_send_http_request'
@@ -62,7 +66,6 @@ type TauriCmd =
| 'cmd_update_http_request' | 'cmd_update_http_request'
| 'cmd_update_settings' | 'cmd_update_settings'
| 'cmd_update_workspace' | 'cmd_update_workspace'
| 'cmd_http_request_actions'
| 'cmd_write_file_dev'; | 'cmd_write_file_dev';
export async function invokeCmd<T>(cmd: TauriCmd, args?: InvokeArgs): Promise<T> { export async function invokeCmd<T>(cmd: TauriCmd, args?: InvokeArgs): Promise<T> {

View File

@@ -39,7 +39,7 @@ export type YaakTheme = YaakColors & {
appHeader: Partial<YaakColors>; appHeader: Partial<YaakColors>;
button: Partial<YaakColors>; button: Partial<YaakColors>;
banner: Partial<YaakColors>; banner: Partial<YaakColors>;
placeholder: Partial<YaakColors>; templateTag: Partial<YaakColors>;
urlBar: Partial<YaakColors>; urlBar: Partial<YaakColors>;
editor: Partial<YaakColors>; editor: Partial<YaakColors>;
input: Partial<YaakColors>; input: Partial<YaakColors>;
@@ -87,7 +87,7 @@ function themeVariables(theme?: Partial<YaakColors>, base?: CSSVariables): CSSVa
return vars; return vars;
} }
function placeholderColorVariables(color: YaakColor): Partial<CSSVariables> { function templateTagColorVariables(color: YaakColor): Partial<CSSVariables> {
return { return {
text: color.lift(0.6), text: color.lift(0.6),
textSubtle: color.lift(0.4), textSubtle: color.lift(0.4),
@@ -201,14 +201,14 @@ function bannerCSS(color: YaakColorKey, colors?: Partial<YaakColors>): string |
); );
} }
function placeholderCSS(color: YaakColorKey, colors?: Partial<YaakColors>): string | null { function templateTagCSS(color: YaakColorKey, colors?: Partial<YaakColors>): string | null {
const yaakColor = colors?.[color]; const yaakColor = colors?.[color];
if (yaakColor == null) { if (yaakColor == null) {
return null; return null;
} }
return [ return [
variablesToCSS(`.x-theme-placeholder--${color}`, placeholderColorVariables(yaakColor)), variablesToCSS(`.x-theme-templateTag--${color}`, templateTagColorVariables(yaakColor)),
].join('\n\n'); ].join('\n\n');
} }
@@ -254,7 +254,7 @@ export function getThemeCSS(theme: YaakTheme): string {
bannerCSS(key as YaakColorKey, theme.components?.banner ?? colors), bannerCSS(key as YaakColorKey, theme.components?.banner ?? colors),
), ),
...Object.keys(colors ?? {}).map((key) => ...Object.keys(colors ?? {}).map((key) =>
placeholderCSS(key as YaakColorKey, theme.components?.placeholder ?? colors), templateTagCSS(key as YaakColorKey, theme.components?.templateTag ?? colors),
), ),
].join('\n\n'); ].join('\n\n');
} catch (err) { } catch (err) {

4
src-web/lib/truncate.ts Normal file
View File

@@ -0,0 +1,4 @@
export function truncate(text: string, len: number): string {
if (text.length <= len) return text;
return text.slice(0, len) + '…';
}

View File

@@ -26,6 +26,13 @@
@apply select-none cursor-default; @apply select-none cursor-default;
} }
input,
textarea {
&::placeholder {
@apply text-placeholder;
}
}
.select-all * { .select-all * {
/*@apply select-all;*/ /*@apply select-all;*/
} }

View File

@@ -1,9 +1,9 @@
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { attachConsole } from '@tauri-apps/plugin-log';
import { type } from '@tauri-apps/plugin-os'; import { type } from '@tauri-apps/plugin-os';
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { pdfjs } from 'react-pdf'; import { pdfjs } from 'react-pdf';
import { attachConsole } from '@tauri-apps/plugin-log';
import { App } from './components/App'; import { App } from './components/App';
import './main.css'; import './main.css';

View File

@@ -69,8 +69,7 @@ module.exports = {
}, },
colors: { colors: {
transparent: 'transparent', transparent: 'transparent',
placeholder: 'var(--textSubtlest)',
// placeholder: 'var(--fg-subtler)', // Now a component?
shadow: 'var(--shadow)', shadow: 'var(--shadow)',
backdrop: 'var(--backdrop)', backdrop: 'var(--backdrop)',
selection: 'var(--selection)', selection: 'var(--selection)',

View File

@@ -20,8 +20,7 @@
"include": [ "include": [
"src-web", "src-web",
"src-web/modules.d.ts", "src-web/modules.d.ts",
"vite.config.ts", "vite.config.ts"
"tailwind.config.cjs"
], ],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }