Compare commits

..

15 Commits

Author SHA1 Message Date
Gregory Schier
736025b12f Move editor search to top 2025-03-19 08:22:57 -07:00
Gregory Schier
cb9e9a67a3 Try registering URL scheme 2025-03-19 07:58:12 -07:00
Gregory Schier
93c323458f Tweak Git history table 2025-03-19 06:59:54 -07:00
Gregory Schier
6f8c03d8c1 Fix git confid for commit 2025-03-19 06:59:43 -07:00
Gregory Schier
afd4228fcf Don't style scrollbars on mac 2025-03-19 06:49:14 -07:00
Gregory Schier
d478e5a12e Hotkey scrolling 2025-03-19 06:48:29 -07:00
Gregory Schier
0db9ebe67d Better Codemirror search match styles 2025-03-19 06:48:07 -07:00
Gregory Schier
80ea5e6b91 Fix autoscroller header scrolling 2025-03-19 06:37:02 -07:00
Gregory Schier
cb773babe1 Nested template functions (#186) 2025-03-18 12:49:19 -07:00
Gregory Schier
b9ed554aca Remove useTemplating prop (#184) 2025-03-18 05:34:38 -07:00
Gregory Schier
f42f3d0e27 Support multi-line params and env vars 2025-03-17 09:29:37 -07:00
Gregory Schier
93ba5b6e5c Fix close bracket bug 2025-03-13 13:09:13 -07:00
Gregory Schier
be11d5968e Fix notification not showing all 2025-03-12 06:41:53 -07:00
Gregory Schier
0828599e4f Don't switch to XML for HTML responses.
Fixes https://feedback.yaak.app/p/issue-with-rendering-html-responses-after-update
2025-03-08 08:34:41 -08:00
dependabot[bot]
f47d22c395 Bump ring from 0.17.8 to 0.17.13 in /src-tauri (#181)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-08 08:11:38 -08:00
43 changed files with 1025 additions and 620 deletions

View File

@@ -60,3 +60,10 @@ Run the app to apply the migrations.
If nothing happens, try `cargo clean` and run the app again.
_Note: Development builds use a separate database location from production builds._
## Lezer Grammer Generation
```sh
# Example
lezer-generator components/core/Editor/<LANG>/<LANG>.grammar > components/core/Editor/<LANG>/<LANG>.ts
```

64
package-lock.json generated
View File

@@ -2772,9 +2772,9 @@
}
},
"node_modules/@tauri-apps/api": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.2.0.tgz",
"integrity": "sha512-R8epOeZl1eJEl603aUMIGb4RXlhPjpgxbGVEaqY+0G5JG9vzV/clNlzTeqc+NLYXVqXcn8mb4c5b9pJIUDEyAg==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.3.0.tgz",
"integrity": "sha512-33Z+0lX2wgZbx1SPFfqvzI6su63hCBkbzv+5NexeYjIx7WA9htdOKoRR7Dh3dJyltqS5/J8vQFyybiRoaL0hlA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
@@ -2981,63 +2981,63 @@
}
},
"node_modules/@tauri-apps/plugin-clipboard-manager": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.0.0.tgz",
"integrity": "sha512-V1sXmbjnwfXt/r48RJMwfUmDMSaP/8/YbH4CLNxt+/sf1eHlIP8PRFdFDQwLN0cNQKu2rqQVbG/Wc/Ps6cDUhw==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.2.2.tgz",
"integrity": "sha512-bZvDLMqfcNmsw7Ag8I49jlaCjdpDvvlJHnpp6P+Gg/3xtpSERdwlDxm7cKGbs2mj46dsw4AuG3RoAgcpwgioUA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.0.0.tgz",
"integrity": "sha512-ApNkejXP2jpPBSifznPPcHTXxu9/YaRW+eJ+8+nYwqp0lLUtebFHG4QhxitM43wwReHE81WAV1DQ/b+2VBftOA==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.2.0.tgz",
"integrity": "sha512-6bLkYK68zyK31418AK5fNccCdVuRnNpbxquCl8IqgFByOgWFivbiIlvb79wpSXi0O+8k8RCSsIpOquebusRVSg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-fs": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.0.0.tgz",
"integrity": "sha512-BNEeQQ5aH8J5SwYuWgRszVyItsmquRuzK2QRkVj8Z0sCsLnSvJFYI3JHRzzr3ltZGq1nMPtblrlZzuKqVzRawA==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.2.0.tgz",
"integrity": "sha512-+08mApuONKI8/sCNEZ6AR8vf5vI9DXD4YfrQ9NQmhRxYKMLVhRW164vdW5BSLmMpuevftpQ2FVoL9EFkfG9Z+g==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-log": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.0.0.tgz",
"integrity": "sha512-C+NII9vzswqnOQE8k7oRtnaw0z5TZsMmnirRhXkCKDEhQQH9841Us/PC1WHtGiAaJ8za1A1JB2xXndEq/47X/w==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.3.1.tgz",
"integrity": "sha512-nnKGHENWt7teqvUlIKxd6bp2wCUrrLvCvajN6CWbyrHBNKPi/pyKELzD511siEMDEdndbiZ/GEhiK0xBtZopRg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.2.2.tgz",
"integrity": "sha512-E/XIHKqGV+FT8PDdkfMETmgPUxcR79Rk8USuzbadD/ZdvsKCfQR5q+6rpZC9zEnG2wzi9lVQM4D3xwrtGGIB8A==",
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.2.6.tgz",
"integrity": "sha512-bSdkuP71ZQRepPOn8BOEdBKYJQvl6+jb160QtJX/i2H9BF6ZySY/kYljh76N2Ne5fJMQRge7rlKoStYQY5Jq1w==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-os": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.0.0.tgz",
"integrity": "sha512-M7hG/nNyQYTJxVG/UhTKhp9mpXriwWzrs9mqDreB8mIgqA3ek5nHLdwRZJWhkKjZrnDT4v9CpA9BhYeplTlAiA==",
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.2.1.tgz",
"integrity": "sha512-cNYpNri2CCc6BaNeB6G/mOtLvg8dFyFQyCUdf2y0K8PIAKGEWdEcu8DECkydU2B+oj4OJihDPD2de5K6cbVl9A==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-shell": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.0.0.tgz",
"integrity": "sha512-OpW2+ycgJLrEoZityWeWYk+6ZWP9VyiAfbO+N/O8VfLkqyOym8kXh7odKDfINx9RAotkSGBtQM4abyKfJDkcUg==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.2.0.tgz",
"integrity": "sha512-iC3Ic1hLmasoboG7BO+7p+AriSoqAwKrIk+Hpk+S/bjTQdXqbl2GbdclghI4gM32X0bls7xHzIFqhRdrlvJeaA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
@@ -15699,14 +15699,14 @@
"@tanstack/react-query": "^5.66.9",
"@tanstack/react-router": "^1.111.3",
"@tanstack/react-virtual": "^3.13.0",
"@tauri-apps/api": "^2.2.0",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-log": "^2.0.0",
"@tauri-apps/plugin-opener": "^2.2.2",
"@tauri-apps/plugin-os": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/plugin-clipboard-manager": "^2.2.2",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-log": "^2.3.1",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.0",
"buffer": "^6.0.3",
"classnames": "^2.5.1",
"cm6-graphql": "^0.0.9",

520
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,7 @@ strip = true # Automatically strip symbols from the binary.
cargo-clippy = []
[build-dependencies]
tauri-build = { version = "2.0.5", features = [] }
tauri-build = { version = "2.0.6", features = [] }
[target.'cfg(target_os = "macos")'.dependencies]
objc = "0.2.7"
@@ -58,15 +58,15 @@ rustls-platform-verifier = "0.5.0"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["raw_value"] }
tauri = { workspace = true, features = ["devtools", "protocol-asset"] }
tauri-plugin-clipboard-manager = "2.2.1"
tauri-plugin-clipboard-manager = "2.2.2"
tauri-plugin-dialog = "2.2.0"
tauri-plugin-fs = "2.2.0"
tauri-plugin-log = { version = "2.2.1", features = ["colored"] }
tauri-plugin-opener = "2.2.5"
tauri-plugin-os = "2.2.0"
tauri-plugin-log = { version = "2.3.1", features = ["colored"] }
tauri-plugin-opener = "2.2.6"
tauri-plugin-os = "2.2.1"
tauri-plugin-shell = { workspace = true }
tauri-plugin-single-instance = "2.2.1"
tauri-plugin-updater = "2.5.0"
tauri-plugin-single-instance = "2.2.2"
tauri-plugin-updater = "2.6.1"
tauri-plugin-window-state = "2.2.1"
tokio = { version = "1.43.0", features = ["sync"] }
tokio-stream = "0.1.17"

View File

@@ -8,6 +8,7 @@ use crate::http_request::send_http_request;
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_template};
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
use crate::uri_scheme::handle_uri_scheme;
use error::Result as YaakResult;
use eventsource_client::{EventParser, SSE};
use log::{debug, error, warn};
@@ -78,6 +79,7 @@ mod render;
#[cfg(target_os = "macos")]
mod tauri_plugin_mac_window;
mod updates;
mod uri_scheme;
mod window;
mod window_menu;
@@ -114,8 +116,8 @@ 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())
async fn cmd_parse_template(template: &str) -> YaakResult<Tokens> {
Ok(Parser::new(template).parse()?)
}
#[tauri::command]
@@ -1905,10 +1907,7 @@ pub fn run() {
cmd_update_workspace_meta,
cmd_write_file_dev,
])
.register_uri_scheme_protocol("yaak", |_app, _req| {
debug!("Testing yaak protocol");
tauri::http::Response::builder().body("Success".as_bytes().to_vec()).unwrap()
})
.register_uri_scheme_protocol("yaak", handle_uri_scheme)
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(|app_handle, event| {

View File

@@ -93,11 +93,12 @@ impl YaakNotifier {
let seen = get_kv(window).await?;
if seen.contains(&notification.id) || (age > Duration::days(2)) {
debug!("Already seen notification {}", notification.id);
return Ok(());
continue;
}
debug!("Got notification {:?}", notification);
let _ = window.emit_to(window.label(), "notification", notification.clone());
break; // Only show one notification
}
Ok(())

View File

@@ -0,0 +1,25 @@
use log::{info, warn};
use tauri::{Manager, Runtime, UriSchemeContext};
pub(crate) fn handle_uri_scheme<R: Runtime>(
a: UriSchemeContext<R>,
req: http::Request<Vec<u8>>,
) -> http::Response<Vec<u8>> {
println!("------------- Yaak URI scheme invoked!");
let uri = req.uri();
let window = a
.app_handle()
.get_webview_window(a.webview_label())
.expect("Failed to get webview window for URI scheme event");
info!("Yaak URI scheme invoked with {uri:?} {window:?}");
let path = uri.path();
if path == "/data/import" {
warn!("TODO: import data")
} else if path == "/plugins/install" {
warn!("TODO: install plugin")
}
let msg = format!("No handler found for {path}");
tauri::http::Response::builder().status(404).body(msg.as_bytes().to_vec()).unwrap()
}

View File

@@ -117,9 +117,9 @@ pub fn git_commit(dir: &Path, message: &str) -> Result<()> {
let tree = repo.find_tree(tree_oid)?;
// Make the signature
let config = git2::Config::open_default()?.snapshot()?;
let name = config.get_str("user.name").unwrap_or("Change Me");
let email = config.get_str("user.email").unwrap_or("change_me@example.com");
let config = repo.config()?.snapshot()?;
let name = config.get_str("user.name").unwrap_or("Unknown");
let email = config.get_str("user.email")?;
let sig = git2::Signature::now(name, email)?;
// Get the current HEAD commit (if it exists)

View File

@@ -5,9 +5,10 @@ edition = "2021"
publish = false
[dependencies]
base64 = "0.22.1"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
log = "0.4.22"
serde = { version = "1.0.208", features = ["derive"] }
serde_json = "1.0.132"
thiserror = { workspace = true }
tokio = { version = "1.39.3", features = ["macros", "rt"] }
ts-rs = { version = "10.0.0" }

View File

@@ -1,3 +1,7 @@
use crate::error::Error::RenderError;
use crate::error::Result;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use ts_rs::TS;
@@ -43,7 +47,13 @@ pub enum Val {
impl Display for Val {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = match self {
Val::Str { text } => format!("'{}'", text.to_string().replace("'", "\'")),
Val::Str { text } => {
if text.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '_' || c == '_') {
format!("'{}'", text)
} else {
format!("b64'{}'", BASE64_URL_SAFE_NO_PAD.encode(text))
}
}
Val::Var { name } => name.to_string(),
Val::Bool { value } => value.to_string(),
Val::Fn { name, args } => {
@@ -108,13 +118,13 @@ impl Parser {
}
}
pub fn parse(&mut self) -> Tokens {
pub fn parse(&mut self) -> Result<Tokens> {
let start_pos = self.pos;
while self.pos < self.chars.len() {
if self.match_str("${[") {
let start_curr = self.pos;
if let Some(t) = self.parse_tag() {
if let Some(t) = self.parse_tag()? {
self.push_token(t);
} else {
self.pos = start_curr;
@@ -131,29 +141,29 @@ impl Parser {
}
self.push_token(Token::Eof);
Tokens {
Ok(Tokens {
tokens: self.tokens.clone(),
}
})
}
fn parse_tag(&mut self) -> Option<Token> {
fn parse_tag(&mut self) -> Result<Option<Token>> {
// Parse up to first identifier
// ${[ my_var...
self.skip_whitespace();
let val = match self.parse_value() {
let val = match self.parse_value()? {
Some(v) => v,
None => return None,
None => return Ok(None),
};
// Parse to closing tag
// ${[ my_var(a, b, c) ]}
self.skip_whitespace();
if !self.match_str("]}") {
return None;
return Ok(None);
}
Some(Token::Tag { val })
Ok(Some(Token::Tag { val }))
}
#[allow(dead_code)]
@@ -167,9 +177,11 @@ impl Parser {
);
}
fn parse_value(&mut self) -> Option<Val> {
if let Some((name, args)) = self.parse_fn() {
fn parse_value(&mut self) -> Result<Option<Val>> {
let v = if let Some((name, args)) = self.parse_fn()? {
Some(Val::Fn { name, args })
} else if let Some(v) = self.parse_string()? {
Some(Val::Str { text: v })
} else if let Some(v) = self.parse_ident() {
if v == "null" {
Some(Val::Null)
@@ -180,38 +192,38 @@ impl Parser {
} else {
Some(Val::Var { name: v })
}
} else if let Some(v) = self.parse_string() {
Some(Val::Str { text: v })
} else {
None
}
};
Ok(v)
}
fn parse_fn(&mut self) -> Option<(String, Vec<FnArg>)> {
fn parse_fn(&mut self) -> Result<Option<(String, Vec<FnArg>)>> {
let start_pos = self.pos;
let name = match self.parse_fn_name() {
Some(v) => v,
None => {
self.pos = start_pos;
return None;
return Ok(None);
}
};
let args = match self.parse_fn_args() {
let args = match self.parse_fn_args()? {
Some(args) => args,
None => {
self.pos = start_pos;
return None;
return Ok(None);
}
};
Some((name, args))
Ok(Some((name, args)))
}
fn parse_fn_args(&mut self) -> Option<Vec<FnArg>> {
fn parse_fn_args(&mut self) -> Result<Option<Vec<FnArg>>> {
if !self.match_str("(") {
return None;
return Ok(None);
}
let start_pos = self.pos;
@@ -221,7 +233,7 @@ impl Parser {
// Fn closed immediately
self.skip_whitespace();
if self.match_str(")") {
return Some(args);
return Ok(Some(args));
}
while self.pos < self.chars.len() {
@@ -231,7 +243,7 @@ impl Parser {
self.skip_whitespace();
self.match_str("=");
self.skip_whitespace();
let value = self.parse_value();
let value = self.parse_value()?;
self.skip_whitespace();
if let (Some(name), Some(value)) = (name.clone(), value.clone()) {
@@ -239,7 +251,7 @@ impl Parser {
} else {
// Didn't find valid thing, so return
self.pos = start_pos;
return None;
return Ok(None);
}
if self.match_str(")") {
@@ -251,7 +263,7 @@ impl Parser {
// If we don't find a comma, that's bad
if !args.is_empty() && !self.match_str(",") {
self.pos = start_pos;
return None;
return Ok(None);
}
if start_pos == self.pos {
@@ -259,7 +271,7 @@ impl Parser {
}
}
Some(args)
Ok(Some(args))
}
fn parse_ident(&mut self) -> Option<String> {
@@ -319,12 +331,17 @@ impl Parser {
Some(text)
}
fn parse_string(&mut self) -> Option<String> {
fn parse_string(&mut self) -> Result<Option<String>> {
let start_pos = self.pos;
let mut text = String::new();
if !self.match_str("'") {
return None;
let mut is_b64 = false;
if self.match_str("b64'") {
is_b64 = true;
} else if self.match_str("'") {
// Nothing
} else {
return Ok(None);
}
let mut found_closing = false;
@@ -350,10 +367,21 @@ impl Parser {
if !found_closing {
self.pos = start_pos;
return None;
return Ok(None);
}
Some(text)
let final_text = if is_b64 {
let decoded = BASE64_URL_SAFE_NO_PAD
.decode(text.clone())
.map_err(|_| RenderError(format!("Failed to decode string {text}")))?;
let decoded = String::from_utf8(decoded)
.map_err(|_| RenderError(format!("Failed to decode utf8 string {text}")))?;
decoded
} else {
text
};
Ok(Some(final_text))
}
fn skip_whitespace(&mut self) {
@@ -410,14 +438,15 @@ impl Parser {
#[cfg(test)]
mod tests {
use crate::error::Result;
use crate::Val::Null;
use crate::*;
#[test]
fn var_simple() {
fn var_simple() -> Result<()> {
let mut p = Parser::new("${[ foo ]}");
assert_eq!(
p.parse().tokens,
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Var { name: "foo".into() }
@@ -425,13 +454,14 @@ mod tests {
Token::Eof
]
);
Ok(())
}
#[test]
fn var_dashes() {
fn var_dashes() -> Result<()> {
let mut p = Parser::new("${[ a-b ]}");
assert_eq!(
p.parse().tokens,
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Var { name: "a-b".into() }
@@ -439,13 +469,15 @@ mod tests {
Token::Eof
]
);
Ok(())
}
#[test]
fn var_underscores() {
fn var_underscores() -> Result<()> {
let mut p = Parser::new("${[ a_b ]}");
assert_eq!(
p.parse().tokens,
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Var { name: "a_b".into() }
@@ -453,13 +485,15 @@ mod tests {
Token::Eof
]
);
Ok(())
}
#[test]
fn var_prefixes() {
fn var_prefixes() -> Result<()> {
let mut p = Parser::new("${[ -a ]}${[ 0a ]}");
assert_eq!(
p.parse().tokens,
p.parse()?.tokens,
vec![
Token::Raw {
// Shouldn't be parsed, because they're invalid
@@ -468,13 +502,15 @@ mod tests {
Token::Eof
]
);
Ok(())
}
#[test]
fn var_underscore_prefix() {
fn var_underscore_prefix() -> Result<()> {
let mut p = Parser::new("${[ _a ]}");
assert_eq!(
p.parse().tokens,
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Var { name: "_a".into() }
@@ -482,13 +518,15 @@ mod tests {
Token::Eof
]
);
Ok(())
}
#[test]
fn var_boolean() {
fn var_boolean() -> Result<()> {
let mut p = Parser::new("${[ true ]}${[ false ]}");
assert_eq!(
p.parse().tokens,
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Bool { value: true },
@@ -499,13 +537,15 @@ mod tests {
Token::Eof
]
);
Ok(())
}
#[test]
fn var_multiple_names_invalid() {
fn var_multiple_names_invalid() -> Result<()> {
let mut p = Parser::new("${[ foo bar ]}");
assert_eq!(
p.parse().tokens,
p.parse()?.tokens,
vec![
Token::Raw {
text: "${[ foo bar ]}".into()
@@ -513,13 +553,15 @@ mod tests {
Token::Eof
]
);
Ok(())
}
#[test]
fn tag_string() {
fn tag_string() -> Result<()> {
let mut p = Parser::new(r#"${[ 'foo \'bar\' baz' ]}"#);
assert_eq!(
p.parse().tokens,
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Str {
@@ -529,13 +571,33 @@ mod tests {
Token::Eof
]
);
Ok(())
}
#[test]
fn var_surrounded() {
fn tag_b64_string() -> Result<()> {
let mut p = Parser::new(r#"${[ b64'Zm9vICdiYXInIGJheg' ]}"#);
assert_eq!(
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Str {
text: r#"foo 'bar' baz"#.into()
}
},
Token::Eof
]
);
Ok(())
}
#[test]
fn var_surrounded() -> Result<()> {
let mut p = Parser::new("Hello ${[ foo ]}!");
assert_eq!(
p.parse().tokens,
p.parse()?.tokens,
vec![
Token::Raw {
text: "Hello ".to_string()
@@ -549,13 +611,15 @@ mod tests {
Token::Eof,
]
);
Ok(())
}
#[test]
fn fn_simple() {
fn fn_simple() -> Result<()> {
let mut p = Parser::new("${[ foo() ]}");
assert_eq!(
p.parse().tokens,
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Fn {
@@ -566,13 +630,15 @@ mod tests {
Token::Eof
]
);
Ok(())
}
#[test]
fn fn_dot_name() {
fn fn_dot_name() -> Result<()> {
let mut p = Parser::new("${[ foo.bar.baz() ]}");
assert_eq!(
p.parse().tokens,
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Fn {
@@ -583,13 +649,15 @@ mod tests {
Token::Eof
]
);
Ok(())
}
#[test]
fn fn_ident_arg() {
fn fn_ident_arg() -> Result<()> {
let mut p = Parser::new("${[ foo(a=bar) ]}");
assert_eq!(
p.parse().tokens,
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Fn {
@@ -603,13 +671,15 @@ mod tests {
Token::Eof
]
);
Ok(())
}
#[test]
fn fn_ident_args() {
fn fn_ident_args() -> Result<()> {
let mut p = Parser::new("${[ foo(a=bar,b = baz, c =qux ) ]}");
assert_eq!(
p.parse().tokens,
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Fn {
@@ -633,13 +703,15 @@ mod tests {
Token::Eof
]
);
Ok(())
}
#[test]
fn fn_mixed_args() {
fn fn_mixed_args() -> Result<()> {
let mut p = Parser::new(r#"${[ foo(aaa=bar,bb='baz \'hi\'', c=qux, z=true ) ]}"#);
assert_eq!(
p.parse().tokens,
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Fn {
@@ -669,13 +741,15 @@ mod tests {
Token::Eof
]
);
Ok(())
}
#[test]
fn fn_nested() {
fn fn_nested() -> Result<()> {
let mut p = Parser::new("${[ foo(b=bar()) ]}");
assert_eq!(
p.parse().tokens,
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Fn {
@@ -692,13 +766,15 @@ mod tests {
Token::Eof
]
);
Ok(())
}
#[test]
fn fn_nested_args() {
fn fn_nested_args() -> Result<()> {
let mut p = Parser::new(r#"${[ outer(a=inner(a=foo, b='i'), c='o') ]}"#);
assert_eq!(
p.parse().tokens,
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Fn {
@@ -730,10 +806,12 @@ mod tests {
Token::Eof
]
);
Ok(())
}
#[test]
fn token_display_var() {
fn token_display_var() -> Result<()> {
assert_eq!(
Val::Var {
name: "foo".to_string()
@@ -741,21 +819,38 @@ mod tests {
.to_string(),
"foo"
);
Ok(())
}
#[test]
fn token_display_str() {
fn token_display_str() -> Result<()> {
assert_eq!(
Val::Str {
text: "Hello You".to_string()
}
.to_string(),
"'Hello You'"
);
Ok(())
}
#[test]
fn token_display_complex_str() -> Result<()> {
assert_eq!(
Val::Str {
text: "Hello 'You'".to_string()
}
.to_string(),
"'Hello \'You\''"
"b64'SGVsbG8gJ1lvdSc'"
);
Ok(())
}
#[test]
fn token_null_fn_arg() {
fn token_null_fn_arg() -> Result<()> {
assert_eq!(
Val::Fn {
name: "fn".to_string(),
@@ -775,10 +870,12 @@ mod tests {
.to_string(),
r#"fn(a='aaa')"#
);
Ok(())
}
#[test]
fn token_display_fn() {
fn token_display_fn() -> Result<()> {
assert_eq!(
Token::Tag {
val: Val::Fn {
@@ -787,7 +884,7 @@ mod tests {
FnArg {
name: "arg".to_string(),
value: Val::Str {
text: "v".to_string()
text: "v 'x'".to_string()
}
},
FnArg {
@@ -800,12 +897,14 @@ mod tests {
}
}
.to_string(),
r#"${[ foo(arg='v', arg2=my_var) ]}"#
r#"${[ foo(arg=b64'diAneCc', arg2=my_var) ]}"#
);
Ok(())
}
#[test]
fn tokens_display() {
fn tokens_display() -> Result<()> {
assert_eq!(
Tokens {
tokens: vec![
@@ -827,5 +926,7 @@ mod tests {
.to_string(),
r#"${[ my_var ]} Some cool text ${[ 'Hello World' ]}"#
);
Ok(())
}
}

View File

@@ -1,6 +1,6 @@
use crate::error::Error::{RenderStackExceededError, VariableNotFound};
use crate::error::Result;
use crate::{FnArg, Parser, Token, Tokens, Val};
use crate::{Parser, Token, Tokens, Val};
use log::warn;
use serde_json::json;
use std::collections::HashMap;
@@ -44,14 +44,14 @@ pub async fn render_json_value_raw<T: TemplateCallback>(
Ok(v)
}
async fn parse_and_render_with_depth<T: TemplateCallback>(
async fn parse_and_render_at_depth<T: TemplateCallback>(
template: &str,
vars: &HashMap<String, String>,
cb: &T,
depth: usize,
) -> Result<String> {
let mut p = Parser::new(template);
let tokens = p.parse();
let tokens = p.parse()?;
render(tokens, vars, cb, depth + 1).await
}
@@ -60,7 +60,7 @@ pub async fn parse_and_render<T: TemplateCallback>(
vars: &HashMap<String, String>,
cb: &T,
) -> Result<String> {
parse_and_render_with_depth(template, vars, cb, 1).await
parse_and_render_at_depth(template, vars, cb, 1).await
}
pub async fn render<T: TemplateCallback>(
@@ -79,7 +79,7 @@ pub async fn render<T: TemplateCallback>(
for t in tokens.tokens {
match t {
Token::Raw { text } => doc_str.push(text),
Token::Tag { val } => doc_str.push(render_tag(val, &vars, cb, depth).await?),
Token::Tag { val } => doc_str.push(render_value(val, &vars, cb, depth).await?),
Token::Eof => {}
}
}
@@ -87,44 +87,31 @@ pub async fn render<T: TemplateCallback>(
Ok(doc_str.join(""))
}
async fn render_tag<T: TemplateCallback>(
async fn render_value<T: TemplateCallback>(
val: Val,
vars: &HashMap<String, String>,
cb: &T,
depth: usize,
) -> Result<String> {
let v = match val {
Val::Str { text } => text.into(),
Val::Str { text } => {
let r = Box::pin(parse_and_render_at_depth(&text, vars, cb, depth)).await?;
r.to_string()
}
Val::Var { name } => match vars.get(name.as_str()) {
Some(v) => {
let r = Box::pin(parse_and_render_with_depth(v, vars, cb, depth)).await?;
let r = Box::pin(parse_and_render_at_depth(v, vars, cb, depth)).await?;
r.to_string()
}
None => return Err(VariableNotFound(name)),
},
Val::Bool { value } => value.to_string(),
Val::Fn { name, args } => {
let empty = "".to_string();
// let empty = "".to_string();
let mut resolved_args: HashMap<String, String> = HashMap::new();
for a in args {
let (k, v) = match a {
FnArg {
name,
value: Val::Str { text },
} => (name.to_string(), text.to_string()),
FnArg {
name,
value: Val::Var { name: var_name },
} => (
name.to_string(),
vars.get(var_name.as_str()).unwrap_or(&empty).to_string(),
),
FnArg { name, value: val } => {
let r = Box::pin(render_tag(val.clone(), vars, cb, depth)).await?;
(name.to_string(), r)
}
};
resolved_args.insert(k, v);
let v = Box::pin(render_value(a.value, vars, cb, depth)).await?;
resolved_args.insert(a.name, v);
}
match cb.run(name.as_str(), resolved_args.clone()).await {
Ok(s) => s,
@@ -253,6 +240,67 @@ mod parse_and_render_tests {
Ok(())
}
#[tokio::test]
async fn render_fn_arg() -> Result<()> {
let vars = HashMap::new();
let template = r#"${[ upper(foo='bar') ]}"#;
let result = r#"BAR"#;
struct CB {}
impl TemplateCallback for CB {
async fn run(&self, fn_name: &str, args: HashMap<String, String>) -> Result<String> {
Ok(match fn_name {
"secret" => "abc".to_string(),
"upper" => args["foo"].to_string().to_uppercase(),
_ => "".to_string(),
})
}
}
assert_eq!(parse_and_render(template, &vars, &CB {}).await?, result.to_string());
Ok(())
}
#[tokio::test]
async fn render_fn_b64_arg_template() -> Result<()> {
let mut vars = HashMap::new();
vars.insert("foo".to_string(), "bar".to_string());
let template = r#"${[ upper(foo=b64'Zm9vICdiYXInIGJheg') ]}"#;
let result = r#"FOO 'BAR' BAZ"#;
struct CB {}
impl TemplateCallback for CB {
async fn run(&self, fn_name: &str, args: HashMap<String, String>) -> Result<String> {
Ok(match fn_name {
"upper" => args["foo"].to_string().to_uppercase(),
_ => "".to_string(),
})
}
}
assert_eq!(parse_and_render(template, &vars, &CB {}).await?, result.to_string());
Ok(())
}
#[tokio::test]
async fn render_fn_arg_template() -> Result<()> {
let mut vars = HashMap::new();
vars.insert("foo".to_string(), "bar".to_string());
let template = r#"${[ upper(foo='${[ foo ]}') ]}"#;
let result = r#"BAR"#;
struct CB {}
impl TemplateCallback for CB {
async fn run(&self, fn_name: &str, args: HashMap<String, String>) -> Result<String> {
Ok(match fn_name {
"secret" => "abc".to_string(),
"upper" => args["foo"].to_string().to_uppercase(),
_ => "".to_string(),
})
}
}
assert_eq!(parse_and_render(template, &vars, &CB {}).await?, result.to_string());
Ok(())
}
#[tokio::test]
async fn render_nested_fn() -> Result<()> {
let vars = HashMap::new();
@@ -277,7 +325,6 @@ mod parse_and_render_tests {
async fn render_fn_err() -> Result<()> {
let vars = HashMap::new();
let template = r#"${[ error() ]}"#;
let result = r#""#;
struct CB {}
impl TemplateCallback for CB {
@@ -286,7 +333,10 @@ mod parse_and_render_tests {
}
}
assert_eq!(parse_and_render(template, &vars, &CB {}).await?, result.to_string());
assert_eq!(
parse_and_render(template, &vars, &CB {}).await,
Err(RenderError("Failed to do it!".to_string()))
);
Ok(())
}
}

View File

@@ -1,80 +0,0 @@
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import type { GrpcRequest, HttpRequest } from '@yaakapp-internal/models';
import { Input } from './core/Input';
import { VStack } from './core/Stacks';
interface Props<T> {
request: T;
}
export function BasicAuth<T extends HttpRequest | GrpcRequest>({ request }: Props<T>) {
const updateHttpRequest = useUpdateAnyHttpRequest();
const updateGrpcRequest = useUpdateAnyGrpcRequest();
return (
<VStack className="py-2 overflow-y-auto h-full" space={2}>
<Input
useTemplating
autocompleteVariables
stateKey={`basic.username.${request.id}`}
forceUpdateKey={request.id}
placeholder="username"
label="Username"
name="username"
size="sm"
defaultValue={`${request.authentication.username}`}
onChange={(username: string) => {
if (request.model === 'http_request') {
updateHttpRequest.mutate({
id: request.id,
update: (r: HttpRequest) => ({
...r,
authentication: { password: r.authentication.password, username },
}),
});
} else {
updateGrpcRequest.mutate({
id: request.id,
update: (r: GrpcRequest) => ({
...r,
authentication: { password: r.authentication.password, username },
}),
});
}
}}
/>
<Input
useTemplating
autocompleteVariables
forceUpdateKey={request?.id}
stateKey={`basic.password.${request.id}`}
placeholder="password"
label="Password"
name="password"
size="sm"
type="password"
defaultValue={`${request.authentication.password}`}
onChange={(password: string) => {
if (request.model === 'http_request') {
updateHttpRequest.mutate({
id: request.id,
update: (r: HttpRequest) => ({
...r,
authentication: { username: r.authentication.username, password },
}),
});
} else {
updateGrpcRequest.mutate({
id: request.id,
update: (r: GrpcRequest) => ({
...r,
authentication: { username: r.authentication.username, password },
}),
});
}
}}
/>
</VStack>
);
}

View File

@@ -1,49 +0,0 @@
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import type { GrpcRequest, HttpRequest } from '@yaakapp-internal/models';
import { Input } from './core/Input';
import { VStack } from './core/Stacks';
interface Props<T> {
request: T;
}
export function BearerAuth<T extends HttpRequest | GrpcRequest>({ request }: Props<T>) {
const updateHttpRequest = useUpdateAnyHttpRequest();
const updateGrpcRequest = useUpdateAnyGrpcRequest();
return (
<VStack className="my-2" space={2}>
<Input
useTemplating
autocompleteVariables
placeholder="token"
stateKey={`bearer.${request.id}`}
type="password"
label="Token"
name="token"
size="sm"
defaultValue={`${request.authentication.token}`}
onChange={(token: string) => {
if (request.model === 'http_request') {
updateHttpRequest.mutate({
id: request.id ?? null,
update: (r: HttpRequest) => ({
...r,
authentication: { token },
}),
});
} else {
updateGrpcRequest.mutate({
id: request.id ?? null,
update: (r: GrpcRequest) => ({
...r,
authentication: { token },
}),
});
}
}}
/>
</VStack>
);
}

View File

@@ -34,7 +34,7 @@ interface Props<T> {
inputs: FormInput[] | undefined | null;
onChange: (value: T) => void;
data: T;
useTemplating?: boolean;
autocompleteFunctions?: boolean;
autocompleteVariables?: boolean;
stateKey: string;
disabled?: boolean;
@@ -44,8 +44,8 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
inputs,
data,
onChange,
useTemplating,
autocompleteVariables,
autocompleteFunctions,
stateKey,
disabled,
}: Props<T>) {
@@ -62,7 +62,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
inputs={inputs}
setDataAttr={setDataAttr}
stateKey={stateKey}
useTemplating={useTemplating}
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}
data={data}
/>
@@ -71,13 +71,16 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
function FormInputs<T extends Record<string, JsonPrimitive>>({
inputs,
autocompleteFunctions,
autocompleteVariables,
stateKey,
useTemplating,
setDataAttr,
data,
disabled,
}: Pick<Props<T>, 'inputs' | 'useTemplating' | 'autocompleteVariables' | 'stateKey' | 'data'> & {
}: Pick<
Props<T>,
'inputs' | 'autocompleteFunctions' | 'autocompleteVariables' | 'stateKey' | 'data'
> & {
setDataAttr: (name: string, value: JsonPrimitive) => void;
disabled?: boolean;
}) {
@@ -112,7 +115,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
key={i}
stateKey={stateKey}
arg={input}
useTemplating={useTemplating || false}
autocompleteFunctions={autocompleteFunctions || false}
autocompleteVariables={autocompleteVariables || false}
onChange={(v) => setDataAttr(input.name, v)}
value={
@@ -126,7 +129,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
key={i}
stateKey={stateKey}
arg={input}
useTemplating={useTemplating || false}
autocompleteFunctions={autocompleteFunctions || false}
autocompleteVariables={autocompleteVariables || false}
onChange={(v) => setDataAttr(input.name, v)}
value={
@@ -175,7 +178,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
inputs={input.inputs}
setDataAttr={setDataAttr}
stateKey={stateKey}
useTemplating={useTemplating}
autocompleteFunctions={autocompleteFunctions || false}
autocompleteVariables={autocompleteVariables}
/>
</div>
@@ -195,7 +198,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
inputs={input.inputs}
setDataAttr={setDataAttr}
stateKey={stateKey}
useTemplating={useTemplating}
autocompleteFunctions={autocompleteFunctions || false}
autocompleteVariables={autocompleteVariables}
/>
</Banner>
@@ -212,14 +215,14 @@ function TextArg({
arg,
onChange,
value,
useTemplating,
autocompleteFunctions,
autocompleteVariables,
stateKey,
}: {
arg: FormInputText;
value: string;
onChange: (v: string) => void;
useTemplating: boolean;
autocompleteFunctions: boolean;
autocompleteVariables: boolean;
stateKey: string;
}) {
@@ -237,7 +240,7 @@ function TextArg({
hideLabel={arg.label == null}
placeholder={arg.placeholder ?? undefined}
autocomplete={arg.completionOptions ? { options: arg.completionOptions } : undefined}
useTemplating={useTemplating}
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}
stateKey={stateKey}
forceUpdateKey={stateKey}
@@ -249,14 +252,14 @@ function EditorArg({
arg,
onChange,
value,
useTemplating,
autocompleteFunctions,
autocompleteVariables,
stateKey,
}: {
arg: FormInputEditor;
value: string;
onChange: (v: string) => void;
useTemplating: boolean;
autocompleteFunctions: boolean;
autocompleteVariables: boolean;
stateKey: string;
}) {
@@ -290,7 +293,7 @@ function EditorArg({
heightMode="auto"
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
placeholder={arg.placeholder ?? undefined}
useTemplating={useTemplating}
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}
stateKey={stateKey}
forceUpdateKey={forceUpdateKey}

View File

@@ -116,7 +116,7 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
};
const EnvironmentEditor = function ({
environment,
environment: activeEnvironment,
className,
}: {
environment: Environment;
@@ -127,8 +127,8 @@ const EnvironmentEditor = function ({
key: 'environmentValueVisibility',
fallback: true,
});
const { subEnvironments } = useEnvironments();
const updateEnvironment = useUpdateEnvironment(environment?.id ?? null);
const { allEnvironments } = useEnvironments();
const updateEnvironment = useUpdateEnvironment(activeEnvironment?.id ?? null);
const handleChange = useCallback<PairEditorProps['onChange']>(
(variables) => updateEnvironment.mutate({ variables }),
[updateEnvironment],
@@ -136,26 +136,28 @@ const EnvironmentEditor = function ({
// Gather a list of env names from other environments, to help the user get them aligned
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
const allVariableNames =
environment == null
? [] // Nothing to autocomplete if we're in the base environment
: subEnvironments
.filter((e) => e.environmentId != null)
.flatMap((e) => e.variables.map((v) => v.name));
const options: GenericCompletionOption[] = [];
const isBaseEnv = activeEnvironment.environmentId == null;
if (isBaseEnv) {
return { options };
}
// Filter out empty strings and variables that already exist
const variableNames = allVariableNames.filter(
(name) => name != '' && !environment.variables.find((v) => v.name === name),
);
const uniqueVariableNames = [...new Set(variableNames)];
const options = uniqueVariableNames.map(
(name): GenericCompletionOption => ({
const allVariables = allEnvironments.flatMap((e) => e?.variables);
const allVariableNames = new Set(allVariables.map((v) => v?.name));
for (const name of allVariableNames) {
const containingEnvs = allEnvironments.filter((e) =>
e.variables.some((v) => v.name === name),
);
const isAlreadyInActive = containingEnvs.find((e) => e.id === activeEnvironment.id);
if (isAlreadyInActive) continue;
options.push({
label: name,
type: 'constant',
}),
);
detail: containingEnvs.map((e) => e.name).join(', '),
});
}
return { options };
}, [subEnvironments, environment]);
}, [activeEnvironment.environmentId, activeEnvironment.id, allEnvironments]);
const validateName = useCallback((name: string) => {
// Empty just means the variable doesn't have a name yet, and is unusable
@@ -167,7 +169,7 @@ const EnvironmentEditor = function ({
<VStack space={4} className={classNames(className, 'pl-4')}>
<HStack space={2} className="justify-between">
<Heading className="w-full flex items-center gap-1">
<div>{environment?.name}</div>
<div>{activeEnvironment?.name}</div>
<IconButton
size="sm"
icon={valueVisibility.value ? 'eye' : 'eye_closed'}
@@ -180,17 +182,18 @@ const EnvironmentEditor = function ({
</HStack>
<div className="h-full pr-2 pb-2">
<PairOrBulkEditor
allowMultilineValues
preferenceName="environment"
nameAutocomplete={nameAutocomplete}
nameAutocompleteVariables={false}
namePlaceholder="VAR_NAME"
nameValidate={validateName}
valueType={valueVisibility.value ? 'text' : 'password'}
valueAutocompleteVariables={true}
forceUpdateKey={environment.id}
pairs={environment.variables}
valueAutocompleteVariables
valueAutocompleteFunctions
forceUpdateKey={activeEnvironment.id}
pairs={activeEnvironment.variables}
onChange={handleChange}
stateKey={`environment.${environment.id}`}
stateKey={`environment.${activeEnvironment.id}`}
/>
</div>
</VStack>

View File

@@ -40,9 +40,12 @@ export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props
return (
<PairEditor
valueAutocompleteFunctions
valueAutocompleteVariables
nameAutocompleteVariables
nameAutocompleteFunctions
allowFileValues
allowMultilineValues
pairs={pairs}
onChange={handleChange}
forceUpdateKey={forceUpdateKey}

View File

@@ -29,8 +29,11 @@ export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Prop
return (
<PairOrBulkEditor
allowMultilineValues
preferenceName="form_urlencoded"
valueAutocompleteFunctions
valueAutocompleteVariables
nameAutocompleteFunctions
nameAutocompleteVariables
namePlaceholder="entry_name"
valuePlaceholder="Value"

View File

@@ -183,7 +183,7 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
onChange={handleChangeVariables}
placeholder="{}"
stateKey={'graphql_vars.' + request.id}
useTemplating
autocompleteFunctions
autocompleteVariables
{...extraEditorProps}
/>

View File

@@ -181,8 +181,8 @@ export function GrpcEditor({
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor
language="json"
autocompleteFunctions
autocompleteVariables
useTemplating
forceUpdateKey={request.id}
defaultValue={request.message}
heightMode="auto"

View File

@@ -21,7 +21,9 @@ export function HeadersEditor({ stateKey, headers, onChange, forceUpdateKey }: P
<PairOrBulkEditor
preferenceName="headers"
stateKey={stateKey}
valueAutocompleteFunctions
valueAutocompleteVariables
nameAutocompleteFunctions
nameAutocompleteVariables
pairs={headers}
onChange={onChange}

View File

@@ -75,7 +75,7 @@ export function HttpAuthenticationEditor({ request }: Props) {
<DynamicForm
disabled={request.authentication.disabled}
autocompleteVariables
useTemplating
autocompleteFunctions
stateKey={`auth.${request.id}.${request.authenticationType}`}
inputs={authConfig.data.args}
data={request.authentication}

View File

@@ -411,7 +411,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
{activeRequest.bodyType === BODY_TYPE_JSON ? (
<Editor
forceUpdateKey={forceUpdateKey}
useTemplating
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={fullHeight ? 'full' : 'auto'}
@@ -423,7 +423,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
) : activeRequest.bodyType === BODY_TYPE_XML ? (
<Editor
forceUpdateKey={forceUpdateKey}
useTemplating
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={fullHeight ? 'full' : 'auto'}
@@ -462,7 +462,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
) : typeof activeRequest.bodyType === 'string' ? (
<Editor
forceUpdateKey={forceUpdateKey}
useTemplating
autocompleteFunctions
autocompleteVariables
language={languageFromContentType(contentType)}
placeholder="..."

View File

@@ -3,8 +3,8 @@ import { HotKeyList } from './core/HotKeyList';
export function KeyboardShortcutsDialog() {
return (
<div className="h-full w-full pb-2">
<HotKeyList hotkeys={hotkeyActions} />
<div className="grid h-full">
<HotKeyList hotkeys={hotkeyActions} className="pb-6" />
</div>
);
}

View File

@@ -2,32 +2,19 @@ import type { LicenseCheckStatus } from '@yaakapp-internal/license';
import { useLicense } from '@yaakapp-internal/license';
import type { ReactNode } from 'react';
import { openSettings } from '../commands/openSettings';
import { appInfo } from '../hooks/useAppInfo';
import { useLicenseConfirmation } from '../hooks/useLicenseConfirmation';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import { Icon } from './core/Icon';
import { HStack } from './core/Stacks';
import { SettingsTab } from './Settings/SettingsTab';
const details: Record<
LicenseCheckStatus['type'] | 'dev' | 'beta',
LicenseCheckStatus['type'],
{ label: ReactNode; color: ButtonProps['color'] } | null
> = {
beta: {
label: (
<HStack space={1}>
<span>Beta Feedback</span>
<Icon size="xs" icon="external_link" />
</HStack>
),
color: 'info',
},
dev: { label: 'Develop', color: 'secondary' },
commercial_use: null,
invalid_license: { label: 'License Error', color: 'danger' },
personal_use: { label: 'Personal Use', color: 'success' },
trialing: { label: 'Personal Use', color: 'success' },
personal_use: { label: 'Personal Use', color: 'notice' },
trialing: { label: 'Personal Use', color: 'notice' },
};
export function LicenseBadge() {
@@ -62,8 +49,7 @@ export function LicenseBadge() {
return null;
}
const checkType = appInfo.version.includes('beta') ? 'beta' : check.data.type;
const detail = details[checkType];
const detail = details[check.data.type];
if (detail == null) {
return null;
}

View File

@@ -5,10 +5,13 @@ import { useMemo, useState } from 'react';
import { useDebouncedValue } from '../hooks/useDebouncedValue';
import { useRenderTemplate } from '../hooks/useRenderTemplate';
import { useTemplateTokensToString } from '../hooks/useTemplateTokensToString';
import { useToggle } from '../hooks/useToggle';
import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode';
import { VStack } from './core/Stacks';
import { HStack, VStack } from './core/Stacks';
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from './DynamicForm';
import { IconButton } from './core/IconButton';
import { Banner } from './core/Banner';
interface Props {
templateFunction: TemplateFunction;
@@ -18,6 +21,7 @@ interface Props {
}
export function TemplateFunctionDialog({ templateFunction, hide, initialTokens, onChange }: Props) {
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(() => {
const initial: Record<string, string> = {};
const initialArgs =
@@ -77,27 +81,65 @@ export function TemplateFunctionDialog({ templateFunction, hide, initialTokens,
const debouncedTagText = useDebouncedValue(tagText.data ?? '', 200);
const rendered = useRenderTemplate(debouncedTagText);
const tooLarge = (rendered.data ?? '').length > 10000;
const tooLarge = rendered.data ? rendered.data.length > 10000 : false;
const dataContainsSecrets = useMemo(() => {
for (const [name, value] of Object.entries(argValues)) {
const isPassword = templateFunction.args.some(
(a) => a.type === 'text' && a.password && a.name === name,
);
if (isPassword && typeof value === 'string' && value && rendered.data?.includes(value)) {
return true;
}
}
return false;
// Only update this on rendered data change to keep secrets hidden on input change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rendered.data]);
return (
<VStack className="pb-3" space={4}>
<h1 className="font-mono !text-base">{templateFunction.name}()</h1>
<DynamicForm
autocompleteVariables
autocompleteFunctions
inputs={templateFunction.args}
data={argValues}
onChange={setArgValues}
stateKey={`template_function.${templateFunction.name}`}
/>
<VStack className="w-full">
<div className="text-sm text-text-subtle">Preview</div>
<InlineCode
className={classNames(
'whitespace-pre select-text cursor-text max-h-[10rem] overflow-y-auto hide-scrollbars',
tooLarge && 'italic text-danger',
)}
>
{tooLarge ? 'too large to preview' : rendered.data || <>&nbsp;</>}
</InlineCode>
<VStack className="w-full" space={1}>
<HStack space={0.5}>
<div className="text-sm text-text-subtle">Rendered Preview</div>
<IconButton
size="xs"
iconSize="sm"
icon={showSecretsInPreview ? 'lock' : 'lock_open'}
title={showSecretsInPreview ? 'Show preview' : 'Hide preview'}
onClick={toggleShowSecretsInPreview}
className={classNames(
'ml-auto text-text-subtlest',
!dataContainsSecrets && 'invisible',
)}
/>
</HStack>
{rendered.error || tagText.error ? (
<Banner color="danger">{`${rendered.error || tagText.error}`}</Banner>
) : (
<InlineCode
className={classNames(
'whitespace-pre select-text cursor-text max-h-[10rem] overflow-y-auto hide-scrollbars',
tooLarge && 'italic text-danger',
)}
>
{dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">------ sensitive values hidden ------</span>
) : tooLarge ? (
'too large to preview'
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
)}
</VStack>
<Button color="primary" onClick={handleDone}>
Done

View File

@@ -68,12 +68,12 @@ export const UrlBar = memo(function UrlBar({
<form onSubmit={handleSubmit} className={classNames('x-theme-urlBar', className)}>
<Input
ref={inputRef}
autocompleteFunctions
autocompleteVariables
stateKey={stateKey}
size="sm"
wrapLines={isFocused}
hideLabel
useTemplating
language="url"
className="px-1.5 py-0.5"
label="Enter URL"

View File

@@ -33,13 +33,16 @@ export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey
<VStack className="h-full">
<PairOrBulkEditor
ref={pairEditor}
allowMultilineValues
forceUpdateKey={forceUpdateKey + urlParametersKey}
nameAutocompleteFunctions
nameAutocompleteVariables
namePlaceholder="param_name"
onChange={onChange}
pairs={pairs}
preferenceName="url_parameters"
stateKey={stateKey}
valueAutocompleteFunctions
valueAutocompleteVariables
valuePlaceholder="Value"
/>

View File

@@ -301,7 +301,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
<TabContent value={TAB_MESSAGE}>
<Editor
forceUpdateKey={forceUpdateKey}
useTemplating
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={fullHeight ? 'full' : 'auto'}

View File

@@ -46,7 +46,7 @@ export function AutoScroller<T>({ data, render, header }: Props<T>) {
}, [autoScroll, data.length]);
return (
<div className="h-full w-full relative grid grid-rows-[auto_minmax(0,1fr)]">
<div className="h-full w-full relative grid grid-rows-[minmax(0,auto)_minmax(0,1fr)]">
{!autoScroll && (
<div className="absolute bottom-0 right-0 m-2">
<IconButton

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react';
import { generateId } from '../../lib/generateId';
import { Editor } from './Editor/Editor';
import type { PairEditorProps, PairWithId } from './PairEditor';
import type { Pair, PairEditorProps, PairWithId } from './PairEditor';
type Props = PairEditorProps;
@@ -16,7 +16,7 @@ export function BulkPairEditor({
const pairsText = useMemo(() => {
return pairs
.filter((p) => !(p.name.trim() === '' && p.value.trim() === ''))
.map((p) => `${p.name}: ${p.value}`)
.map(pairToLine)
.join('\n');
}, [pairs]);
@@ -33,7 +33,7 @@ export function BulkPairEditor({
return (
<Editor
useTemplating
autocompleteFunctions
autocompleteVariables
stateKey={`bulk_pair.${stateKey}`}
forceUpdateKey={forceUpdateKey}
@@ -45,12 +45,17 @@ export function BulkPairEditor({
);
}
function pairToLine(pair: Pair) {
const value = pair.value.replaceAll('\n', '\\n');
return `${pair.name}: ${value}`;
}
function lineToPair(line: string): PairWithId {
const [, name, value] = line.match(/^(:?[^:]+):\s+(.*)$/) ?? [];
return {
enabled: true,
name: (name ?? '').trim(),
value: (value ?? '').trim(),
value: (value ?? '').replaceAll('\\n', '\n').trim(),
id: generateId(),
};
}

View File

@@ -37,6 +37,9 @@
@apply w-full;
/* Important! Ensure it spans the entire width */
@apply w-full text-text px-0;
/* So the search highlight border is not cut off by editor view */
@apply pl-[1px];
}
.cm-placeholder {
@@ -127,6 +130,29 @@
}
}
/* Style search matches */
.cm-searchMatch {
@apply bg-transparent !important;
@apply rounded-[2px] outline outline-1;
&.cm-searchMatch-selected {
@apply outline-text;
@apply bg-text !important;
&, * {
@apply text-surface font-semibold !important;
}
}
}
/*.cm-searchMatch {*/
/* @apply bg-transparent !important;*/
/* @apply outline outline-[1.5px] outline-text-subtlest rounded-sm;*/
/* &.cm-searchMatch-selected {*/
/* @apply outline-text;*/
/* & * {*/
/* @apply text-text font-semibold;*/
/* }*/
/* }*/
/*}*/
/* Obscure text for password fields */
.cm-wrapper.cm-obscure-text .cm-line {
-webkit-text-security: disc;

View File

@@ -55,36 +55,36 @@ const keymapExtensions: Record<EditorKeymap, Extension> = {
};
export interface EditorProps {
id?: string;
readOnly?: boolean;
disabled?: boolean;
type?: 'text' | 'password';
className?: string;
heightMode?: 'auto' | 'full';
language?: EditorLanguage | 'pairs' | 'url';
forceUpdateKey?: string | number;
actions?: ReactNode;
autoFocus?: boolean;
autoSelect?: boolean;
autocomplete?: GenericCompletionConfig;
autocompleteFunctions?: boolean;
autocompleteVariables?: boolean;
className?: string;
defaultValue?: string | null;
placeholder?: string;
tooltipContainer?: HTMLElement;
useTemplating?: boolean;
disableTabIndent?: boolean;
disabled?: boolean;
extraExtensions?: Extension[];
forceUpdateKey?: string | number;
format?: (v: string) => Promise<string>;
heightMode?: 'auto' | 'full';
hideGutter?: boolean;
id?: string;
language?: EditorLanguage | 'pairs' | 'url';
onBlur?: () => void;
onChange?: (value: string) => void;
onFocus?: () => void;
onKeyDown?: (e: KeyboardEvent) => void;
onPaste?: (value: string) => void;
onPasteOverwrite?: (e: ClipboardEvent, value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
onKeyDown?: (e: KeyboardEvent) => void;
placeholder?: string;
readOnly?: boolean;
singleLine?: boolean;
wrapLines?: boolean;
disableTabIndent?: boolean;
format?: (v: string) => Promise<string>;
autocomplete?: GenericCompletionConfig;
autocompleteVariables?: boolean;
extraExtensions?: Extension[];
actions?: ReactNode;
hideGutter?: boolean;
stateKey: string | null;
tooltipContainer?: HTMLElement;
type?: 'text' | 'password';
wrapLines?: boolean;
}
const stateFields = { history: historyField, folds: foldState };
@@ -94,34 +94,34 @@ const emptyExtension: Extension = [];
export const Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
{
readOnly,
type,
heightMode,
language,
actions,
autoFocus,
autoSelect,
placeholder,
useTemplating,
autocomplete,
autocompleteFunctions,
autocompleteVariables,
className,
defaultValue,
disableTabIndent,
disabled,
extraExtensions,
forceUpdateKey,
format,
heightMode,
hideGutter,
language,
onBlur,
onChange,
onFocus,
onKeyDown,
onPaste,
onPasteOverwrite,
onFocus,
onBlur,
onKeyDown,
className,
disabled,
placeholder,
readOnly,
singleLine,
format,
autocomplete,
extraExtensions,
autocompleteVariables,
actions,
wrapLines,
disableTabIndent,
hideGutter,
stateKey,
type,
wrapLines,
}: EditorProps,
ref,
) {
@@ -129,6 +129,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const allEnvironmentVariables = useActiveEnvironmentVariables();
const environmentVariables = autocompleteVariables ? allEnvironmentVariables : emptyVariables;
const useTemplating = !!(autocompleteFunctions || autocompleteVariables || autocomplete);
if (settings && wrapLines === undefined) {
wrapLines = settings.editorSoftWrap;
@@ -265,7 +266,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const initialTokens = await parseTemplate(tagValue);
showDialog({
id: 'template-function',
id: 'template-function-'+Math.random(), // Allow multiple at once
size: 'sm',
title: 'Configure Function',
description: fn.description,
@@ -340,16 +341,19 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
[focusParamValue],
);
const completionOptions = useTemplateFunctionCompletionOptions(onClickFunction);
const completionOptions = useTemplateFunctionCompletionOptions(
onClickFunction,
!!autocompleteFunctions,
);
// Update the language extension when the language changes
useEffect(() => {
if (cm.current === null) return;
const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({
useTemplating,
language,
environmentVariables,
useTemplating,
autocomplete,
completionOptions,
onClickVariable,
@@ -360,13 +364,13 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
}, [
language,
autocomplete,
useTemplating,
environmentVariables,
onClickFunction,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
completionOptions,
useTemplating,
]);
// Initialize the editor when ref mounts
@@ -381,8 +385,8 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
try {
const languageCompartment = new Compartment();
const langExt = getLanguageExtension({
language,
useTemplating,
language,
completionOptions,
autocomplete,
environmentVariables,

View File

@@ -20,7 +20,7 @@ import {
} from '@codemirror/language';
import { lintKeymap } from '@codemirror/lint';
import { searchKeymap } from '@codemirror/search';
import { search, searchKeymap } from '@codemirror/search';
import type { Extension } from '@codemirror/state';
import { EditorState } from '@codemirror/state';
import {
@@ -74,7 +74,7 @@ export const syntaxHighlightStyle = HighlightStyle.define([
const syntaxTheme = EditorView.theme({}, { dark: true });
const closeBracketsExts: Extension = [closeBrackets(), keymap.of([...closeBracketsKeymap])];
const closeBracketsExtensions: Extension = [closeBrackets(), keymap.of([...closeBracketsKeymap])];
const syntaxExtensions: Record<NonNullable<EditorProps['language']>, LanguageSupport | null> = {
graphql: null,
@@ -88,11 +88,11 @@ const syntaxExtensions: Record<NonNullable<EditorProps['language']>, LanguageSup
markdown: markdown(),
};
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ['json', 'javascript'];
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ['json', 'javascript', 'graphql'];
export function getLanguageExtension({
language,
useTemplating = false,
useTemplating,
language = 'text',
environmentVariables,
autocomplete,
onClickVariable,
@@ -100,27 +100,33 @@ export function getLanguageExtension({
onClickPathParameter,
completionOptions,
}: {
useTemplating: boolean;
environmentVariables: EnvironmentVariable[];
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void;
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
onClickPathParameter: (name: string) => void;
completionOptions: TwigCompletionOption[];
} & Pick<EditorProps, 'language' | 'useTemplating' | 'autocomplete'>) {
if (language === 'graphql') {
return graphql();
}
} & Pick<EditorProps, 'language' | 'autocomplete'>) {
const extraExtensions: Extension[] = [];
const base = syntaxExtensions[language ?? 'text'] ?? text();
if (!useTemplating) {
return base;
if (language === 'url') {
extraExtensions.push(pathParametersPlugin(onClickPathParameter));
}
const extraExtensions: Extension[] =
language === 'url' ? [pathParametersPlugin(onClickPathParameter)] : [];
// Only close brackets on languages that need it
if (language && closeBracketsFor.includes(language)) {
extraExtensions.push(closeBracketsExts);
extraExtensions.push(closeBracketsExtensions);
}
// GraphQL is a special exception
if (language === 'graphql') {
return [graphql(), extraExtensions];
}
const base = syntaxExtensions[language ?? 'text'] ?? text();
if (!useTemplating) {
return [base, extraExtensions];
}
return twig({
@@ -159,6 +165,7 @@ export const readonlyExtensions = [
];
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
search({ top: true }),
hideGutter
? []
: [

View File

@@ -13,7 +13,7 @@ interface Props {
export const HotKeyList = ({ hotkeys, bottomSlot, className }: Props) => {
return (
<div className={classNames(className, 'h-full flex items-center justify-center')}>
<div className="px-4 grid gap-2 grid-cols-[auto_auto]">
<div className="grid gap-2 grid-cols-[auto_auto]">
{hotkeys.map((hotkey) => (
<Fragment key={hotkey}>
<HotKeyLabel className="truncate" action={hotkey} />

View File

@@ -69,6 +69,7 @@ const icons = {
left_panel_hidden: lucide.PanelLeftOpenIcon,
left_panel_visible: lucide.PanelLeftCloseIcon,
lock: lucide.LockIcon,
lock_open: lucide.LockOpenIcon,
magic_wand: lucide.Wand2Icon,
merge: lucide.MergeIcon,
minus: lucide.MinusIcon,

View File

@@ -13,13 +13,13 @@ import { HStack } from './Stacks';
export type InputProps = Pick<
EditorProps,
| 'language'
| 'useTemplating'
| 'autocomplete'
| 'forceUpdateKey'
| 'disabled'
| 'autoFocus'
| 'autoSelect'
| 'autocompleteVariables'
| 'autocompleteFunctions'
| 'onKeyDown'
| 'readOnly'
> & {

View File

@@ -1,4 +1,3 @@
import { formatSize } from '@yaakapp-internal/lib/formatSize';
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import {
@@ -24,7 +23,6 @@ import { Button } from './Button';
import { Checkbox } from './Checkbox';
import type { DropdownItem } from './Dropdown';
import { Dropdown } from './Dropdown';
import type { EditorProps } from './Editor/Editor';
import { Editor } from './Editor/Editor';
import type { GenericCompletionConfig } from './Editor/genericCompletion';
import { Icon } from './Icon';
@@ -41,9 +39,11 @@ export interface PairEditorRef {
export type PairEditorProps = {
allowFileValues?: boolean;
allowMultilineValues?: boolean;
className?: string;
forceUpdateKey?: string;
nameAutocomplete?: GenericCompletionConfig;
nameAutocompleteFunctions?: boolean;
nameAutocompleteVariables?: boolean;
namePlaceholder?: string;
nameValidate?: InputProps['validate'];
@@ -52,6 +52,7 @@ export type PairEditorProps = {
pairs: Pair[];
stateKey: InputProps['stateKey'];
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
valueAutocompleteFunctions?: boolean;
valueAutocompleteVariables?: boolean;
valuePlaceholder?: string;
valueType?: 'text' | 'password';
@@ -79,9 +80,11 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
{
stateKey,
allowFileValues,
allowMultilineValues,
className,
forceUpdateKey,
nameAutocomplete,
nameAutocompleteFunctions,
nameAutocompleteVariables,
namePlaceholder,
nameValidate,
@@ -89,6 +92,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
onChange,
pairs: originalPairs,
valueAutocomplete,
valueAutocompleteFunctions,
valueAutocompleteVariables,
valuePlaceholder,
valueType,
@@ -229,6 +233,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
{hoveredIndex === i && <DropMarker />}
<PairEditorRow
allowFileValues={allowFileValues}
allowMultilineValues={allowMultilineValues}
className="py-1"
forceFocusNamePairId={forceFocusNamePairId}
forceFocusValuePairId={forceFocusValuePairId}
@@ -236,6 +241,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
index={i}
isLast={isLast}
nameAutocomplete={nameAutocomplete}
nameAutocompleteFunctions={nameAutocompleteFunctions}
nameAutocompleteVariables={nameAutocompleteVariables}
namePlaceholder={namePlaceholder}
nameValidate={nameValidate}
@@ -247,6 +253,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
pair={p}
stateKey={stateKey}
valueAutocomplete={valueAutocomplete}
valueAutocompleteFunctions={valueAutocompleteFunctions}
valueAutocompleteVariables={valueAutocompleteVariables}
valuePlaceholder={valuePlaceholder}
valueType={valueType}
@@ -256,12 +263,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
);
})}
{!showAll && pairs.length > MAX_INITIAL_PAIRS && (
<Button
onClick={toggleShowAll}
variant="border"
className="m-2"
size="xs"
>
<Button onClick={toggleShowAll} variant="border" className="m-2" size="xs">
Show {pairs.length - MAX_INITIAL_PAIRS} More
</Button>
)}
@@ -289,13 +291,16 @@ type PairEditorRowProps = {
} & Pick<
PairEditorProps,
| 'allowFileValues'
| 'allowMultilineValues'
| 'forceUpdateKey'
| 'nameAutocomplete'
| 'nameAutocompleteVariables'
| 'namePlaceholder'
| 'nameValidate'
| 'nameAutocompleteFunctions'
| 'stateKey'
| 'valueAutocomplete'
| 'valueAutocompleteFunctions'
| 'valueAutocompleteVariables'
| 'valuePlaceholder'
| 'valueType'
@@ -304,6 +309,7 @@ type PairEditorRowProps = {
function PairEditorRow({
allowFileValues,
allowMultilineValues,
className,
forceFocusNamePairId,
forceFocusValuePairId,
@@ -311,9 +317,10 @@ function PairEditorRow({
index,
isLast,
nameAutocomplete,
nameAutocompleteVariables,
namePlaceholder,
nameValidate,
nameAutocompleteFunctions,
nameAutocompleteVariables,
onChange,
onDelete,
onEnd,
@@ -322,6 +329,7 @@ function PairEditorRow({
pair,
stateKey,
valueAutocomplete,
valueAutocompleteFunctions,
valueAutocompleteVariables,
valuePlaceholder,
valueType,
@@ -330,7 +338,6 @@ function PairEditorRow({
const ref = useRef<HTMLDivElement>(null);
const nameInputRef = useRef<EditorView>(null);
const valueInputRef = useRef<EditorView>(null);
const valueLanguage = languageFromContentType(pair.contentType ?? null);
useEffect(() => {
if (forceFocusNamePairId === pair.id) {
@@ -347,17 +354,6 @@ function PairEditorRow({
const handleFocus = useCallback(() => onFocus?.(pair), [onFocus, pair]);
const handleDelete = useCallback(() => onDelete?.(pair, false), [onDelete, pair]);
const deleteItems = useMemo(
(): DropdownItem[] => [
{
label: 'Delete',
onSelect: handleDelete,
color: 'danger',
},
],
[handleDelete],
);
const handleChangeEnabled = useMemo(
() => (enabled: boolean) => onChange({ ...pair, enabled }),
[onChange, pair],
@@ -396,11 +392,27 @@ function PairEditorRow({
hide={hide}
onChange={handleChangeValueText}
defaultValue={pair.value}
language={valueLanguage}
contentType={pair.contentType ?? null}
/>
),
}),
[handleChangeValueText, pair.name, pair.value, valueLanguage],
[handleChangeValueText, pair.contentType, pair.name, pair.value],
);
const defaultItems = useMemo(
(): DropdownItem[] => [
{
label: 'Edit Multi-line',
onSelect: handleEditMultiLineValue,
hidden: !allowMultilineValues,
},
{
label: 'Delete',
onSelect: handleDelete,
color: 'danger',
},
],
[allowMultilineValues, handleDelete, handleEditMultiLineValue],
);
const [, connectDrop] = useDrop<Pair>(
@@ -484,7 +496,6 @@ function PairEditorRow({
<Input
ref={nameInputRef}
hideLabel
useTemplating
stateKey={`name.${pair.id}.${stateKey}`}
wrapLines={false}
readOnly={pair.readOnlyName}
@@ -501,6 +512,7 @@ function PairEditorRow({
placeholder={namePlaceholder ?? 'name'}
autocomplete={nameAutocomplete}
autocompleteVariables={nameAutocompleteVariables}
autocompleteFunctions={nameAutocompleteFunctions}
/>
)}
<div className="w-full grid grid-cols-[minmax(0,1fr)_auto] gap-1 items-center">
@@ -524,20 +536,19 @@ function PairEditorRow({
size="sm"
onClick={handleEditMultiLineValue}
title={pair.value}
className="text-xs font-mono"
>
Edit {formatSize(pair.value.length)}
{pair.value.split('\n').join(' ')}
</Button>
) : (
<Input
ref={valueInputRef}
hideLabel
useTemplating
stateKey={`value.${pair.id}.${stateKey}`}
wrapLines={false}
size="sm"
containerClassName={classNames(isLast && 'border-dashed')}
validate={valueValidate}
language={valueLanguage}
forceUpdateKey={forceUpdateKey}
defaultValue={pair.value}
label="Value"
@@ -547,6 +558,7 @@ function PairEditorRow({
type={isLast ? 'text' : valueType}
placeholder={valuePlaceholder ?? 'value'}
autocomplete={valueAutocomplete?.(pair.name)}
autocompleteFunctions={valueAutocompleteFunctions}
autocompleteVariables={valueAutocompleteVariables}
/>
)}
@@ -562,7 +574,7 @@ function PairEditorRow({
editMultiLine={handleEditMultiLineValue}
/>
) : (
<Dropdown items={deleteItems}>
<Dropdown items={defaultItems}>
<IconButton
iconSize="sm"
size="xs"
@@ -641,6 +653,7 @@ function FileActionsDropdown({
onSelect: onDelete,
variant: 'danger',
leftSlot: <Icon icon="trash" />,
color: 'danger',
},
],
[editMultiLine, onChangeContentType, onChangeFile, onDelete, pair.contentType, pair.isFile],
@@ -673,16 +686,17 @@ function isPairEmpty(pair: Pair): boolean {
function MultilineEditDialog({
defaultValue,
language,
contentType,
onChange,
hide,
}: {
defaultValue: string;
language: EditorProps['language'];
contentType: string | null;
onChange: (value: string) => void;
hide: () => void;
}) {
const [value, setValue] = useState<string>(defaultValue);
const language = languageFromContentType(contentType, value);
return (
<div className="w-[100vw] max-w-[40rem] h-[50vh] max-h-full grid grid-rows-[minmax(0,1fr)_auto]">
<Editor
@@ -691,6 +705,8 @@ function MultilineEditDialog({
language={language}
onChange={setValue}
stateKey={null}
autocompleteFunctions
autocompleteVariables
/>
<div>
<Button

View File

@@ -57,7 +57,7 @@ export function TableHeaderCell({
className?: string;
}) {
return (
<th className={classNames(className, 'py-2 [&:not(:first-child)]:pl-4 text-left w-0')}>
<th className={classNames(className, 'py-2 [&:not(:first-child)]:pl-4 text-left w-0 text-text-subtle')}>
{children}
</th>
);

View File

@@ -28,7 +28,7 @@ export function HistoryDialog({ log }: Props) {
{log.map((l, i) => (
<TableRow key={i}>
<TruncatedWideTableCell>{l.message || <em className="text-text-subtle">No message</em>}</TruncatedWideTableCell>
<TableCell>{l.author.name ?? 'Unknown'}</TableCell>
<TableCell><span title={`Email: ${l.author.email}`}>{l.author.name || 'Unknown'}</span></TableCell>
<TableCell className="text-text-subtle">
<span title={l.when}>{formatDistanceToNowStrict(l.when)} ago</span>
</TableCell>

View File

@@ -11,9 +11,13 @@ const templateFunctionsAtom = atom<TemplateFunction[]>([]);
export function useTemplateFunctionCompletionOptions(
onClick: (fn: TemplateFunction, ragTag: string, pos: number) => void,
enabled: boolean,
) {
const templateFunctions = useAtomValue(templateFunctionsAtom);
return useMemo<TwigCompletionOption[]>(() => {
if (!enabled) {
return [];
}
return (
templateFunctions.map((fn) => {
const NUM_ARGS = 2;
@@ -35,7 +39,7 @@ export function useTemplateFunctionCompletionOptions(
};
}) ?? []
);
}, [onClick, templateFunctions]);
}, [enabled, onClick, templateFunctions]);
}
export function useSubscribeTemplateFunctions() {

View File

@@ -11,7 +11,13 @@ export function languageFromContentType(
} else if (justContentType.includes('xml')) {
return 'xml';
} else if (justContentType.includes('html')) {
return detectFromContent(content, 'html');
const detected = detectFromContent(content, 'html');
if (detected === 'xml') {
// If it's detected as XML, but is already HTML, don't change it
return 'html';
} else {
return detected;
}
} else if (justContentType.includes('javascript')) {
return 'javascript';
}
@@ -26,7 +32,6 @@ function detectFromContent(
if (content == null) return 'text';
const firstBytes = content.slice(0, 20).trim();
console.log("FIRST BYTES", firstBytes);
if (firstBytes.startsWith('{') || firstBytes.startsWith('[')) {
return 'json';

View File

@@ -62,11 +62,14 @@
}
}
/* Style the scrollbars */
* {
/* Style the scrollbars
* Mac doesn't like this (especially in CodeMirror) so we only do it on non-macos platforms. On Mac,
* styling the scrollbar seems to cause them to not show up at all most of the time
*/
html:not([data-platform="macos"]) * {
::-webkit-scrollbar-corner,
::-webkit-scrollbar {
@apply w-1.5 h-1.5;
@apply w-[10px] h-[10px];
}
.scrollbar-track,
@@ -78,7 +81,7 @@
&:hover {
&.scrollbar-thumb,
&::-webkit-scrollbar-thumb {
@apply bg-surface-highlight hover:bg-surface-highlight rounded-full;
@apply bg-text-subtlest hover:bg-text-subtle rounded-[2px];
}
}
}

View File

@@ -26,14 +26,14 @@
"@tanstack/react-query": "^5.66.9",
"@tanstack/react-router": "^1.111.3",
"@tanstack/react-virtual": "^3.13.0",
"@tauri-apps/api": "^2.2.0",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-log": "^2.0.0",
"@tauri-apps/plugin-opener": "^2.2.2",
"@tauri-apps/plugin-os": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/plugin-clipboard-manager": "^2.2.2",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-log": "^2.3.1",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.0",
"buffer": "^6.0.3",
"classnames": "^2.5.1",
"cm6-graphql": "^0.0.9",