Merge pull request #256

* Update environment model to get ready for request/folder environments

* Folder environments in UI

* Folder environments working

* Tweaks and fixes

* Tweak environment encryption UX

* Tweak environment encryption UX

* Address comments

* Update fn name

* Add tsc back to lint rules

* Update src-web/components/EnvironmentEditor.tsx

* Merge remote-tracking branch 'origin/folder-environments' into folder…
This commit is contained in:
Gregory Schier
2025-09-21 07:54:26 -07:00
committed by GitHub
parent 46b049c72b
commit eb3d1c409b
85 changed files with 776 additions and 534 deletions

View File

@@ -12,6 +12,6 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
} }
} }

View File

@@ -12,6 +12,6 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
} }
} }

View File

@@ -12,6 +12,6 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
} }
} }

View File

@@ -12,6 +12,6 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
} }
} }

View File

@@ -12,6 +12,6 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
} }
} }

View File

@@ -12,7 +12,7 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}, },
"dependencies": { "dependencies": {
"jsonwebtoken": "^9.0.2" "jsonwebtoken": "^9.0.2"

View File

@@ -12,6 +12,6 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
} }
} }

View File

@@ -12,7 +12,7 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}, },
"dependencies": { "dependencies": {
"jsonpath-plus": "^10.3.0" "jsonpath-plus": "^10.3.0"

View File

@@ -7,7 +7,7 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}, },
"dependencies": { "dependencies": {
"@xmldom/xmldom": "^0.9.8", "@xmldom/xmldom": "^0.9.8",

View File

@@ -7,7 +7,7 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}, },
"dependencies": { "dependencies": {
"shell-quote": "^1.8.1" "shell-quote": "^1.8.1"

View File

@@ -7,7 +7,7 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}, },
"dependencies": { "dependencies": {
"yaml": "^2.4.2" "yaml": "^2.4.2"

View File

@@ -7,7 +7,7 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}, },
"dependencies": { "dependencies": {
"openapi-to-postmanv2": "^5.0.0", "openapi-to-postmanv2": "^5.0.0",

View File

@@ -8,6 +8,6 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
} }
} }

View File

@@ -7,6 +7,6 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
} }
} }

View File

@@ -7,6 +7,6 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
} }
} }

View File

@@ -7,6 +7,6 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
} }
} }

View File

@@ -7,6 +7,6 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
} }
} }

View File

@@ -7,6 +7,6 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
} }
} }

View File

@@ -7,7 +7,7 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}, },
"dependencies": { "dependencies": {
"jsonpath-plus": "^10.3.0" "jsonpath-plus": "^10.3.0"

View File

@@ -7,6 +7,6 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
} }
} }

View File

@@ -7,6 +7,6 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
} }
} }

View File

@@ -7,6 +7,6 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
} }
} }

View File

@@ -7,7 +7,7 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}, },
"dependencies": { "dependencies": {
"jsonpath-plus": "^10.3.0", "jsonpath-plus": "^10.3.0",

View File

@@ -5,7 +5,7 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}, },
"dependencies": { "dependencies": {
"date-fns": "^4.1.0" "date-fns": "^4.1.0"

View File

@@ -7,7 +7,7 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}, },
"dependencies": { "dependencies": {
"uuid": "^11.1.0" "uuid": "^11.1.0"

View File

@@ -7,7 +7,7 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}, },
"dependencies": { "dependencies": {
"@xmldom/xmldom": "^0.9.8", "@xmldom/xmldom": "^0.9.8",

View File

@@ -7,6 +7,6 @@
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
} }
} }

View File

@@ -43,14 +43,14 @@ pub async fn send_http_request<R: Runtime>(
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let app_handle = window.app_handle().clone(); let app_handle = window.app_handle().clone();
let plugin_manager = app_handle.state::<PluginManager>(); let plugin_manager = app_handle.state::<PluginManager>();
let (settings, workspace) = { let settings = window.db().get_settings();
let db = window.db(); let workspace = window.db().get_workspace(&unrendered_request.workspace_id)?;
let settings = db.get_settings(); let environment_id = environment.map(|e| e.id);
let workspace = db.get_workspace(&unrendered_request.workspace_id)?; let environment_chain = window.db().resolve_environments(
(settings, workspace) &unrendered_request.workspace_id,
}; unrendered_request.folder_id.as_deref(),
let base_environment = environment_id.as_deref(),
app_handle.db().get_base_environment(&unrendered_request.workspace_id)?; )?;
let response_id = og_response.id.clone(); let response_id = og_response.id.clone();
let response = Arc::new(Mutex::new(og_response.clone())); let response = Arc::new(Mutex::new(og_response.clone()));
@@ -76,20 +76,17 @@ pub async fn send_http_request<R: Runtime>(
RenderPurpose::Send, RenderPurpose::Send,
); );
let request = let request = match render_http_request(&resolved_request, environment_chain, &cb).await {
match render_http_request(&resolved_request, &base_environment, environment.as_ref(), &cb) Ok(r) => r,
.await Err(e) => {
{ return Ok(response_err(
Ok(r) => r, &app_handle,
Err(e) => { &*response.lock().await,
return Ok(response_err( e.to_string(),
&app_handle, &update_source,
&*response.lock().await, ));
e.to_string(), }
&update_source, };
));
}
};
let mut url_string = request.url.clone(); let mut url_string = request.url.clone();

View File

@@ -30,8 +30,9 @@ use yaak_common::window::WorkspaceWindowTrait;
use yaak_grpc::manager::{DynamicMessage, GrpcHandle}; use yaak_grpc::manager::{DynamicMessage, GrpcHandle};
use yaak_grpc::{Code, ServiceDefinition, deserialize_message, serialize_message}; use yaak_grpc::{Code, ServiceDefinition, deserialize_message, serialize_message};
use yaak_models::models::{ use yaak_models::models::{
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, GrpcEventType, AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, Plugin, Workspace, WorkspaceMeta, GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, Plugin, Workspace,
WorkspaceMeta,
}; };
use yaak_models::query_manager::QueryManagerExt; use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources}; use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
@@ -110,15 +111,11 @@ async fn cmd_render_template<R: Runtime>(
workspace_id: &str, workspace_id: &str,
environment_id: Option<&str>, environment_id: Option<&str>,
) -> YaakResult<String> { ) -> YaakResult<String> {
let environment = match environment_id { let environment_chain =
Some(id) => app_handle.db().get_environment(id).ok(), app_handle.db().resolve_environments(workspace_id, None, environment_id)?;
None => None,
};
let base_environment = app_handle.db().get_base_environment(&workspace_id)?;
let result = render_template( let result = render_template(
template, template,
&base_environment, environment_chain,
environment.as_ref(),
&PluginTemplateCallback::new( &PluginTemplateCallback::new(
&app_handle, &app_handle,
&PluginWindowContext::new(&window), &PluginWindowContext::new(&window),
@@ -147,21 +144,19 @@ async fn cmd_grpc_reflect<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
grpc_handle: State<'_, Mutex<GrpcHandle>>, grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> YaakResult<Vec<ServiceDefinition>> { ) -> YaakResult<Vec<ServiceDefinition>> {
let environment = match environment_id {
Some(id) => app_handle.db().get_environment(id).ok(),
None => None,
};
let unrendered_request = app_handle.db().get_grpc_request(request_id)?; let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?; let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
let base_environment = let environment_chain = app_handle.db().resolve_environments(
app_handle.db().get_base_environment(&unrendered_request.workspace_id)?; &unrendered_request.workspace_id,
unrendered_request.folder_id.as_deref(),
environment_id,
)?;
let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?; let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?;
let req = render_grpc_request( let req = render_grpc_request(
&resolved_request, &resolved_request,
&base_environment, environment_chain,
environment.as_ref(),
&PluginTemplateCallback::new( &PluginTemplateCallback::new(
&app_handle, &app_handle,
&PluginWindowContext::new(&window), &PluginWindowContext::new(&window),
@@ -196,20 +191,18 @@ async fn cmd_grpc_go<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
grpc_handle: State<'_, Mutex<GrpcHandle>>, grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> YaakResult<String> { ) -> YaakResult<String> {
let environment = match environment_id {
Some(id) => app_handle.db().get_environment(id).ok(),
None => None,
};
let unrendered_request = app_handle.db().get_grpc_request(request_id)?; let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?; let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
let base_environment = let environment_chain = app_handle.db().resolve_environments(
app_handle.db().get_base_environment(&unrendered_request.workspace_id)?; &unrendered_request.workspace_id,
unrendered_request.folder_id.as_deref(),
environment_id,
)?;
let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?; let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?;
let request = render_grpc_request( let request = render_grpc_request(
&resolved_request, &resolved_request,
&base_environment, environment_chain.clone(),
environment.as_ref(),
&PluginTemplateCallback::new( &PluginTemplateCallback::new(
&app_handle, &app_handle,
&PluginWindowContext::new(&window), &PluginWindowContext::new(&window),
@@ -300,9 +293,8 @@ async fn cmd_grpc_go<R: Runtime>(
let cb = { let cb = {
let cancelled_rx = cancelled_rx.clone(); let cancelled_rx = cancelled_rx.clone();
let app_handle = app_handle.clone(); let app_handle = app_handle.clone();
let environment_chain = environment_chain.clone();
let window = window.clone(); let window = window.clone();
let base_environment = base_environment.clone();
let environment = environment.clone();
let base_msg = base_msg.clone(); let base_msg = base_msg.clone();
let method_desc = method_desc.clone(); let method_desc = method_desc.clone();
@@ -327,12 +319,12 @@ async fn cmd_grpc_go<R: Runtime>(
let app_handle = app_handle.clone(); let app_handle = app_handle.clone();
let base_msg = base_msg.clone(); let base_msg = base_msg.clone();
let method_desc = method_desc.clone(); let method_desc = method_desc.clone();
let environment_chain = environment_chain.clone();
let msg = block_in_place(|| { let msg = block_in_place(|| {
tauri::async_runtime::block_on(async { tauri::async_runtime::block_on(async {
render_template( render_template(
msg.as_str(), msg.as_str(),
&base_environment, environment_chain,
environment.as_ref(),
&PluginTemplateCallback::new( &PluginTemplateCallback::new(
&app_handle, &app_handle,
&PluginWindowContext::new(&window), &PluginWindowContext::new(&window),
@@ -396,12 +388,12 @@ async fn cmd_grpc_go<R: Runtime>(
let window = window.clone(); let window = window.clone();
let app_handle = app_handle.clone(); let app_handle = app_handle.clone();
let base_event = base_msg.clone(); let base_event = base_msg.clone();
let environment_chain = environment_chain.clone();
let req = request.clone(); let req = request.clone();
let msg = if req.message.is_empty() { "{}".to_string() } else { req.message }; let msg = if req.message.is_empty() { "{}".to_string() } else { req.message };
let msg = render_template( let msg = render_template(
msg.as_str(), msg.as_str(),
&base_environment.clone(), environment_chain,
environment.as_ref(),
&PluginTemplateCallback::new( &PluginTemplateCallback::new(
&app_handle, &app_handle,
&PluginWindowContext::new(&window), &PluginWindowContext::new(&window),
@@ -833,30 +825,25 @@ async fn cmd_get_http_authentication_config<R: Runtime>(
plugin_manager: State<'_, PluginManager>, plugin_manager: State<'_, PluginManager>,
auth_name: &str, auth_name: &str,
values: HashMap<String, JsonPrimitive>, values: HashMap<String, JsonPrimitive>,
request_id: &str, request: AnyModel,
environment_id: Option<&str>, environment_id: Option<&str>,
workspace_id: &str,
) -> YaakResult<GetHttpAuthenticationConfigResponse> { ) -> YaakResult<GetHttpAuthenticationConfigResponse> {
let base_environment = window.db().get_base_environment(&workspace_id)?; let (workspace_id, folder_id) = match request.clone() {
let environment = match environment_id { AnyModel::HttpRequest(m) => (m.workspace_id, m.folder_id),
Some(id) => match window.db().get_environment(id) { AnyModel::GrpcRequest(m) => (m.workspace_id, m.folder_id),
Ok(env) => Some(env), AnyModel::WebsocketRequest(m) => (m.workspace_id, m.folder_id),
Err(e) => { AnyModel::Folder(m) => (m.workspace_id, m.folder_id),
warn!("Failed to find environment by id {id} {}", e); AnyModel::Workspace(m) => (m.id, None),
None m => {
} return Err(GenericError(format!("Unsupported model to call auth config {m:?}")));
}, },
None => None,
}; };
let environment_chain =
window.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
Ok(plugin_manager Ok(plugin_manager
.get_http_authentication_config( .get_http_authentication_config(&window, environment_chain, auth_name, values, request.id())
&window,
&base_environment,
environment.as_ref(),
auth_name,
values,
request_id,
)
.await?) .await?)
} }
@@ -907,30 +894,29 @@ async fn cmd_call_http_authentication_action<R: Runtime>(
auth_name: &str, auth_name: &str,
action_index: i32, action_index: i32,
values: HashMap<String, JsonPrimitive>, values: HashMap<String, JsonPrimitive>,
model_id: &str, model: AnyModel,
workspace_id: &str,
environment_id: Option<&str>, environment_id: Option<&str>,
) -> YaakResult<()> { ) -> YaakResult<()> {
let base_environment = window.db().get_base_environment(&workspace_id)?; let (workspace_id, folder_id) = match model.clone() {
let environment = match environment_id { AnyModel::HttpRequest(m) => (m.workspace_id, m.folder_id),
Some(id) => match window.db().get_environment(id) { AnyModel::GrpcRequest(m) => (m.workspace_id, m.folder_id),
Ok(env) => Some(env), AnyModel::WebsocketRequest(m) => (m.workspace_id, m.folder_id),
Err(e) => { AnyModel::Folder(m) => (m.workspace_id, m.folder_id),
warn!("Failed to find environment by id {id} {}", e); AnyModel::Workspace(m) => (m.id, None),
None m => {
} return Err(GenericError(format!("Unsupported model to call auth {m:?}")));
}, }
None => None,
}; };
let environment_chain =
window.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
Ok(plugin_manager Ok(plugin_manager
.call_http_authentication_action( .call_http_authentication_action(
&window, &window,
&base_environment, environment_chain,
environment.as_ref(),
auth_name, auth_name,
action_index, action_index,
values, values,
model_id, &model.id(),
) )
.await?) .await?)
} }

View File

@@ -74,20 +74,15 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
let workspace = let workspace =
workspace_from_window(&window).expect("Failed to get workspace_id from window URL"); workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
let environment = environment_from_window(&window); let environment_id = environment_from_window(&window).map(|e| e.id);
let base_environment = app_handle let environment_chain = window
.db() .db()
.get_base_environment(&workspace.id) .resolve_environments(&workspace.id, None, environment_id.as_deref())
.expect("Failed to get base environment"); .expect("Failed to resolve environments");
let cb = PluginTemplateCallback::new(app_handle, &window_context, req.purpose); let cb = PluginTemplateCallback::new(app_handle, &window_context, req.purpose);
let grpc_request = render_grpc_request( let grpc_request = render_grpc_request(&req.grpc_request, environment_chain, &cb)
&req.grpc_request, .await
&base_environment, .expect("Failed to render grpc request");
environment.as_ref(),
&cb,
)
.await
.expect("Failed to render grpc request");
Some(InternalEventPayload::RenderGrpcRequestResponse(RenderGrpcRequestResponse { Some(InternalEventPayload::RenderGrpcRequestResponse(RenderGrpcRequestResponse {
grpc_request, grpc_request,
})) }))
@@ -98,20 +93,15 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
let workspace = let workspace =
workspace_from_window(&window).expect("Failed to get workspace_id from window URL"); workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
let environment = environment_from_window(&window); let environment_id = environment_from_window(&window).map(|e| e.id);
let base_environment = app_handle let environment_chain = window
.db() .db()
.get_base_environment(&workspace.id) .resolve_environments(&workspace.id, None, environment_id.as_deref())
.expect("Failed to get base environment"); .expect("Failed to resolve environments");
let cb = PluginTemplateCallback::new(app_handle, &window_context, req.purpose); let cb = PluginTemplateCallback::new(app_handle, &window_context, req.purpose);
let http_request = render_http_request( let http_request = render_http_request(&req.http_request, environment_chain, &cb)
&req.http_request, .await
&base_environment, .expect("Failed to render http request");
environment.as_ref(),
&cb,
)
.await
.expect("Failed to render http request");
Some(InternalEventPayload::RenderHttpRequestResponse(RenderHttpRequestResponse { Some(InternalEventPayload::RenderHttpRequestResponse(RenderHttpRequestResponse {
http_request, http_request,
})) }))
@@ -122,13 +112,13 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
let workspace = let workspace =
workspace_from_window(&window).expect("Failed to get workspace_id from window URL"); workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
let environment = environment_from_window(&window); let environment_id = environment_from_window(&window).map(|e| e.id);
let base_environment = app_handle let environment_chain = window
.db() .db()
.get_base_environment(&workspace.id) .resolve_environments(&workspace.id, None, environment_id.as_deref())
.expect("Failed to get base environment"); .expect("Failed to resolve environments");
let cb = PluginTemplateCallback::new(app_handle, &window_context, req.purpose); let cb = PluginTemplateCallback::new(app_handle, &window_context, req.purpose);
let data = render_json_value(req.data, &base_environment, environment.as_ref(), &cb) let data = render_json_value(req.data, environment_chain, &cb)
.await .await
.expect("Failed to render template"); .expect("Failed to render template");
Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data })) Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data }))

View File

@@ -5,35 +5,32 @@ use yaak_models::models::{
Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter, Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
}; };
use yaak_models::render::make_vars_hashmap; use yaak_models::render::make_vars_hashmap;
use yaak_templates::{parse_and_render, render_json_value_raw, TemplateCallback}; use yaak_templates::{TemplateCallback, parse_and_render, render_json_value_raw};
pub async fn render_template<T: TemplateCallback>( pub async fn render_template<T: TemplateCallback>(
template: &str, template: &str,
base_environment: &Environment, environment_chain: Vec<Environment>,
environment: Option<&Environment>,
cb: &T, cb: &T,
) -> yaak_templates::error::Result<String> { ) -> yaak_templates::error::Result<String> {
let vars = &make_vars_hashmap(base_environment, environment); let vars = &make_vars_hashmap(environment_chain);
render(template, vars, cb).await render(template, vars, cb).await
} }
pub async fn render_json_value<T: TemplateCallback>( pub async fn render_json_value<T: TemplateCallback>(
value: Value, value: Value,
base_environment: &Environment, environment_chain: Vec<Environment>,
environment: Option<&Environment>,
cb: &T, cb: &T,
) -> yaak_templates::error::Result<Value> { ) -> yaak_templates::error::Result<Value> {
let vars = &make_vars_hashmap(base_environment, environment); let vars = &make_vars_hashmap(environment_chain);
render_json_value_raw(value, vars, cb).await render_json_value_raw(value, vars, cb).await
} }
pub async fn render_grpc_request<T: TemplateCallback>( pub async fn render_grpc_request<T: TemplateCallback>(
r: &GrpcRequest, r: &GrpcRequest,
base_environment: &Environment, environment_chain: Vec<Environment>,
environment: Option<&Environment>,
cb: &T, cb: &T,
) -> yaak_templates::error::Result<GrpcRequest> { ) -> yaak_templates::error::Result<GrpcRequest> {
let vars = &make_vars_hashmap(base_environment, environment); let vars = &make_vars_hashmap(environment_chain);
let mut metadata = Vec::new(); let mut metadata = Vec::new();
for p in r.metadata.clone() { for p in r.metadata.clone() {
@@ -62,11 +59,10 @@ pub async fn render_grpc_request<T: TemplateCallback>(
pub async fn render_http_request<T: TemplateCallback>( pub async fn render_http_request<T: TemplateCallback>(
r: &HttpRequest, r: &HttpRequest,
base_environment: &Environment, environment_chain: Vec<Environment>,
environment: Option<&Environment>,
cb: &T, cb: &T,
) -> yaak_templates::error::Result<HttpRequest> { ) -> yaak_templates::error::Result<HttpRequest> {
let vars = &make_vars_hashmap(base_environment, environment); let vars = &make_vars_hashmap(environment_chain);
let mut url_parameters = Vec::new(); let mut url_parameters = Vec::new();
for p in r.url_parameters.clone() { for p in r.url_parameters.clone() {

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, color: string | null, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, variables: Array<EnvironmentVariable>, color: string | null, parentModel: string, parentId: string | null, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -24,7 +24,7 @@ pub(crate) fn find_ssh_key() -> Option<PathBuf> {
None None
} }
pub(crate) fn get_current_branch(repo: &Repository) -> Result<Option<Branch>> { pub(crate) fn get_current_branch(repo: &Repository) -> Result<Option<Branch<'_>>> {
for b in repo.branches(None)? { for b in repo.branches(None)? {
let branch = b?.0; let branch = b?.0;
if branch.is_head() { if branch.is_head() {
@@ -101,7 +101,7 @@ pub(crate) fn get_default_remote_in_repo(repo: &Repository) -> Result<String> {
return Ok(DEFAULT_REMOTE_NAME.into()); return Ok(DEFAULT_REMOTE_NAME.into());
} }
// if only one remote exists pick that // if only one remote exists, pick that
if remotes.len() == 1 { if remotes.len() == 1 {
let first_remote = remotes let first_remote = remotes
.iter() .iter()

View File

@@ -14,7 +14,7 @@ export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, }; export type EncryptedKey = { encryptedKey: string, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, color: string | null, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, variables: Array<EnvironmentVariable>, color: string | null, parentModel: string, parentId: string | null, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -0,0 +1,62 @@
-- Create temporary table for migration
CREATE TABLE environments__new
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at DATETIME,
workspace_id TEXT NOT NULL
REFERENCES workspaces ON DELETE CASCADE,
name TEXT NOT NULL,
variables TEXT DEFAULT '[]' NOT NULL,
model TEXT DEFAULT 'environment',
public BOOLEAN DEFAULT FALSE,
color TEXT,
-- NEW
parent_model TEXT DEFAULT 'workspace' NOT NULL,
parent_id TEXT
);
-- Backfill the data from the old table
-- - base=1 -> (workspace, NULL)
-- - base=0 -> (environment, id_of_workspace_base) (fallback to workspace,NULL if none)
INSERT INTO environments__new
(id, created_at, updated_at, deleted_at, workspace_id, name, variables, model, public, color, parent_model, parent_id)
SELECT
e.id,
e.created_at,
e.updated_at,
e.deleted_at,
e.workspace_id,
e.name,
e.variables,
e.model,
e.public,
e.color,
CASE
WHEN e.base = 1 THEN 'workspace'
WHEN (
SELECT COUNT(1)
FROM environments b
WHERE b.workspace_id = e.workspace_id AND b.base = 1
) > 0 THEN 'environment'
ELSE 'workspace'
END AS parent_model,
CASE
WHEN e.base = 1 THEN NULL
ELSE (
SELECT b.id
FROM environments b
WHERE b.workspace_id = e.workspace_id AND b.base = 1
ORDER BY b.created_at ASC, b.id ASC
LIMIT 1
)
END AS parent_id
FROM environments e;
-- Move everything to the new table
DROP TABLE environments;
ALTER TABLE environments__new
RENAME TO environments;

View File

@@ -89,7 +89,7 @@ impl<'a> DbContext<'a> {
col: impl IntoColumnRef, col: impl IntoColumnRef,
value: impl Into<SimpleExpr>, value: impl Into<SimpleExpr>,
limit: Option<u64>, limit: Option<u64>,
) -> crate::error::Result<Vec<M>> ) -> Result<Vec<M>>
where where
M: Into<AnyModel> + Clone + UpsertModelInfo, M: Into<AnyModel> + Clone + UpsertModelInfo,
{ {

View File

@@ -29,6 +29,9 @@ pub enum Error {
#[error("Multiple base environments for {0}. Delete duplicates before continuing.")] #[error("Multiple base environments for {0}. Delete duplicates before continuing.")]
MultipleBaseEnvironments(String), MultipleBaseEnvironments(String),
#[error("Multiple folder environments for {0}. Delete duplicates before continuing.")]
MultipleFolderEnvironments(String),
#[error("unknown error")] #[error("unknown error")]
Unknown, Unknown,

View File

@@ -533,9 +533,10 @@ pub struct Environment {
pub name: String, pub name: String,
pub public: bool, pub public: bool,
pub base: bool,
pub variables: Vec<EnvironmentVariable>, pub variables: Vec<EnvironmentVariable>,
pub color: Option<String>, pub color: Option<String>,
pub parent_model: String,
pub parent_id: Option<String>,
} }
impl UpsertModelInfo for Environment { impl UpsertModelInfo for Environment {
@@ -568,7 +569,8 @@ impl UpsertModelInfo for Environment {
(CreatedAt, upsert_date(source, self.created_at)), (CreatedAt, upsert_date(source, self.created_at)),
(UpdatedAt, upsert_date(source, self.updated_at)), (UpdatedAt, upsert_date(source, self.updated_at)),
(WorkspaceId, self.workspace_id.into()), (WorkspaceId, self.workspace_id.into()),
(Base, self.base.into()), (ParentId, self.parent_id.into()),
(ParentModel, self.parent_model.into()),
(Color, self.color.into()), (Color, self.color.into()),
(Name, self.name.trim().into()), (Name, self.name.trim().into()),
(Public, self.public.into()), (Public, self.public.into()),
@@ -579,7 +581,8 @@ impl UpsertModelInfo for Environment {
fn update_columns() -> Vec<impl IntoIden> { fn update_columns() -> Vec<impl IntoIden> {
vec![ vec![
EnvironmentIden::UpdatedAt, EnvironmentIden::UpdatedAt,
EnvironmentIden::Base, EnvironmentIden::ParentId,
EnvironmentIden::ParentModel,
EnvironmentIden::Color, EnvironmentIden::Color,
EnvironmentIden::Name, EnvironmentIden::Name,
EnvironmentIden::Public, EnvironmentIden::Public,
@@ -598,7 +601,8 @@ impl UpsertModelInfo for Environment {
workspace_id: row.get("workspace_id")?, workspace_id: row.get("workspace_id")?,
created_at: row.get("created_at")?, created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?, updated_at: row.get("updated_at")?,
base: row.get("base")?, parent_id: row.get("parent_id")?,
parent_model: row.get("parent_model")?,
color: row.get("color")?, color: row.get("color")?,
name: row.get("name")?, name: row.get("name")?,
public: row.get("public")?, public: row.get("public")?,
@@ -2072,6 +2076,17 @@ macro_rules! define_any_model {
)* )*
} }
impl AnyModel {
#[inline]
pub fn id(&self) -> &str {
match self {
$(
AnyModel::$type(inner) => &inner.id,
)*
}
}
}
$( $(
impl From<$type> for AnyModel { impl From<$type> for AnyModel {
fn from(value: $type) -> Self { fn from(value: $type) -> Self {

View File

@@ -1,5 +1,7 @@
use crate::db_context::DbContext; use crate::db_context::DbContext;
use crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments}; use crate::error::Error::{
MissingBaseEnvironment, MultipleBaseEnvironments, MultipleFolderEnvironments,
};
use crate::error::Result; use crate::error::Result;
use crate::models::{Environment, EnvironmentIden, EnvironmentVariable}; use crate::models::{Environment, EnvironmentIden, EnvironmentVariable};
use crate::util::UpdateSource; use crate::util::UpdateSource;
@@ -10,21 +12,31 @@ impl<'a> DbContext<'a> {
self.find_one(EnvironmentIden::Id, id) self.find_one(EnvironmentIden::Id, id)
} }
pub fn get_environment_by_folder_id(&self, folder_id: &str) -> Result<Option<Environment>> {
let environments: Vec<Environment> =
self.find_many(EnvironmentIden::ParentId, folder_id, None)?;
if environments.len() > 1 {
return Err(MultipleFolderEnvironments(folder_id.to_string()));
}
Ok(environments.get(0).cloned())
}
pub fn get_base_environment(&self, workspace_id: &str) -> Result<Environment> { pub fn get_base_environment(&self, workspace_id: &str) -> Result<Environment> {
let environments = self.list_environments_ensure_base(workspace_id)?; let environments = self.list_environments_ensure_base(workspace_id)?;
let base_environments = let base_environments = environments
environments.into_iter().filter(|e| e.base).collect::<Vec<Environment>>(); .into_iter()
.filter(|e| e.parent_id.is_none())
.collect::<Vec<Environment>>();
if base_environments.len() > 1 { if base_environments.len() > 1 {
return Err(MultipleBaseEnvironments(workspace_id.to_string())); return Err(MultipleBaseEnvironments(workspace_id.to_string()));
} }
let base_environment = base_environments.into_iter().find(|e| e.base).ok_or( Ok(base_environments.first().cloned().ok_or(
// Should never happen because one should be created above if it does not exist // Should never happen because one should be created above if it does not exist
MissingBaseEnvironment(workspace_id.to_string()), MissingBaseEnvironment(workspace_id.to_string()),
)?; )?)
Ok(base_environment)
} }
/// Lists environments and will create a base environment if one doesn't exist /// Lists environments and will create a base environment if one doesn't exist
@@ -32,13 +44,12 @@ impl<'a> DbContext<'a> {
let mut environments = let mut environments =
self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?; self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?;
let base_environment = environments.iter().find(|e| e.base); let base_environment = environments.iter().find(|e| e.parent_id.is_none());
if let None = base_environment { if let None = base_environment {
let e = self.upsert_environment( let e = self.upsert_environment(
&Environment { &Environment {
workspace_id: workspace_id.to_string(), workspace_id: workspace_id.to_string(),
base: true,
name: "Global Variables".to_string(), name: "Global Variables".to_string(),
..Default::default() ..Default::default()
}, },
@@ -98,4 +109,43 @@ impl<'a> DbContext<'a> {
source, source,
) )
} }
pub fn resolve_environments(
&self,
workspace_id: &str,
folder_id: Option<&str>,
active_environment_id: Option<&str>,
) -> Result<Vec<Environment>> {
let mut environments = Vec::new();
if let Some(folder_id) = folder_id {
let folder = self.get_folder(folder_id)?;
// Add current folder's environment
if let Some(e) = self.get_environment_by_folder_id(folder_id)? {
environments.push(e);
};
// Recurse up
let ancestors = self.resolve_environments(
workspace_id,
folder.folder_id.as_deref(),
active_environment_id,
)?;
environments.extend(ancestors);
} else {
// Add active and base environments
if let Some(id) = active_environment_id {
if let Ok(e) = self.get_environment(&id) {
// Add active sub environment
environments.push(e);
};
};
// Add the base environment
environments.push(self.get_base_environment(workspace_id)?);
}
Ok(environments)
}
} }

View File

@@ -1,10 +1,7 @@
use crate::connection_or_tx::ConnectionOrTx; use crate::connection_or_tx::ConnectionOrTx;
use crate::db_context::DbContext; use crate::db_context::DbContext;
use crate::error::Result; use crate::error::Result;
use crate::models::{ use crate::models::{Environment, EnvironmentIden, Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden, WebsocketRequest, WebsocketRequestIden};
Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader,
HttpRequestIden, WebsocketRequest, WebsocketRequestIden,
};
use crate::util::UpdateSource; use crate::util::UpdateSource;
use serde_json::Value; use serde_json::Value;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -37,6 +34,10 @@ impl<'a> DbContext<'a> {
self.delete_websocket_request(&m, source)?; self.delete_websocket_request(&m, source)?;
} }
for e in self.find_many(EnvironmentIden::ParentId, fid, None)? {
self.delete_environment(&e, source)?;
}
// Recurse down into child folders // Recurse down into child folders
for folder in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? { for folder in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
self.delete_folder(&folder, source)?; self.delete_folder(&folder, source)?;
@@ -99,6 +100,17 @@ impl<'a> DbContext<'a> {
)?; )?;
} }
for m in self.find_many::<Environment>(EnvironmentIden::ParentId, fid, None)? {
self.upsert_environment(
&Environment {
id: "".into(),
parent_id: Some(new_folder.id.clone()),
..m
},
source,
)?;
}
for m in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? { for m in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
// Recurse down // Recurse down
self.duplicate_folder( self.duplicate_folder(

View File

@@ -64,7 +64,7 @@ impl QueryManager {
} }
} }
pub fn connect(&self) -> DbContext { pub fn connect(&self) -> DbContext<'_> {
let conn = self let conn = self
.pool .pool
.lock() .lock()

View File

@@ -1,14 +1,10 @@
use std::collections::HashMap;
use crate::models::{Environment, EnvironmentVariable}; use crate::models::{Environment, EnvironmentVariable};
use std::collections::HashMap;
pub fn make_vars_hashmap( pub fn make_vars_hashmap(environment_chain: Vec<Environment>) -> HashMap<String, String> {
base_environment: &Environment,
environment: Option<&Environment>,
) -> HashMap<String, String> {
let mut variables = HashMap::new(); let mut variables = HashMap::new();
variables = add_variable_to_map(variables, &base_environment.variables);
if let Some(e) = environment { for e in environment_chain.iter().rev() {
variables = add_variable_to_map(variables, &e.variables); variables = add_variable_to_map(variables, &e.variables);
} }
@@ -31,4 +27,3 @@ fn add_variable_to_map(
map map
} }

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, color: string | null, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, variables: Array<EnvironmentVariable>, color: string | null, parentModel: string, parentId: string | null, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -584,8 +584,7 @@ impl PluginManager {
pub async fn get_http_authentication_config<R: Runtime>( pub async fn get_http_authentication_config<R: Runtime>(
&self, &self,
window: &WebviewWindow<R>, window: &WebviewWindow<R>,
base_environment: &Environment, environment_chain: Vec<Environment>,
environment: Option<&Environment>,
auth_name: &str, auth_name: &str,
values: HashMap<String, JsonPrimitive>, values: HashMap<String, JsonPrimitive>,
request_id: &str, request_id: &str,
@@ -596,7 +595,7 @@ impl PluginManager {
.find_map(|(p, r)| if r.name == auth_name { Some(p) } else { None }) .find_map(|(p, r)| if r.name == auth_name { Some(p) } else { None })
.ok_or(PluginNotFoundErr(auth_name.into()))?; .ok_or(PluginNotFoundErr(auth_name.into()))?;
let vars = &make_vars_hashmap(&base_environment, environment); let vars = &make_vars_hashmap(environment_chain);
let cb = PluginTemplateCallback::new( let cb = PluginTemplateCallback::new(
window.app_handle(), window.app_handle(),
&PluginWindowContext::new(&window), &PluginWindowContext::new(&window),
@@ -629,14 +628,13 @@ impl PluginManager {
pub async fn call_http_authentication_action<R: Runtime>( pub async fn call_http_authentication_action<R: Runtime>(
&self, &self,
window: &WebviewWindow<R>, window: &WebviewWindow<R>,
base_environment: &Environment, environment_chain: Vec<Environment>,
environment: Option<&Environment>,
auth_name: &str, auth_name: &str,
action_index: i32, action_index: i32,
values: HashMap<String, JsonPrimitive>, values: HashMap<String, JsonPrimitive>,
model_id: &str, model_id: &str,
) -> Result<()> { ) -> Result<()> {
let vars = &make_vars_hashmap(&base_environment, environment); let vars = &make_vars_hashmap(environment_chain);
let rendered_values = render_json_value_raw( let rendered_values = render_json_value_raw(
json!(values), json!(values),
vars, vars,

View File

@@ -1,20 +1,12 @@
use crate::error::Result; use crate::error::Result;
use log::{info, warn}; use log::{info, warn};
use serde;
use serde::Deserialize;
use std::net::SocketAddr; use std::net::SocketAddr;
use tauri::path::BaseDirectory; use tauri::path::BaseDirectory;
use tauri::{AppHandle, Manager, Runtime}; use tauri::{AppHandle, Manager, Runtime};
use tauri_plugin_shell::ShellExt;
use tauri_plugin_shell::process::CommandEvent; use tauri_plugin_shell::process::CommandEvent;
use tauri_plugin_shell::ShellExt;
use tokio::sync::watch::Receiver; use tokio::sync::watch::Receiver;
#[derive(Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
struct PortFile {
port: i32,
}
pub async fn start_nodejs_plugin_runtime<R: Runtime>( pub async fn start_nodejs_plugin_runtime<R: Runtime>(
app: &AppHandle<R>, app: &AppHandle<R>,
addr: SocketAddr, addr: SocketAddr,

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, color: string | null, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, variables: Array<EnvironmentVariable>, color: string | null, parentModel: string, parentId: string | null, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -109,24 +109,18 @@ pub(crate) async fn send<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
ws_manager: State<'_, Mutex<WebsocketManager>>, ws_manager: State<'_, Mutex<WebsocketManager>>,
) -> Result<WebsocketConnection> { ) -> Result<WebsocketConnection> {
let (connection, unrendered_request) = { let connection = app_handle.db().get_websocket_connection(connection_id)?;
let db = app_handle.db(); let unrendered_request = app_handle.db().get_websocket_request(&connection.request_id)?;
let connection = db.get_websocket_connection(connection_id)?; let environment_chain = app_handle.db().resolve_environments(
let unrendered_request = db.get_websocket_request(&connection.request_id)?; &unrendered_request.workspace_id,
(connection, unrendered_request) unrendered_request.folder_id.as_deref(),
}; environment_id,
let environment = match environment_id { )?;
Some(id) => Some(app_handle.db().get_environment(id)?),
None => None,
};
let base_environment =
app_handle.db().get_base_environment(&unrendered_request.workspace_id)?;
let (resolved_request, _auth_context_id) = let (resolved_request, _auth_context_id) =
resolve_websocket_request(&window, &unrendered_request)?; resolve_websocket_request(&window, &unrendered_request)?;
let request = render_websocket_request( let request = render_websocket_request(
&resolved_request, &resolved_request,
&base_environment, environment_chain,
environment.as_ref(),
&PluginTemplateCallback::new( &PluginTemplateCallback::new(
&app_handle, &app_handle,
&PluginWindowContext::new(&window), &PluginWindowContext::new(&window),
@@ -192,19 +186,17 @@ pub(crate) async fn connect<R: Runtime>(
ws_manager: State<'_, Mutex<WebsocketManager>>, ws_manager: State<'_, Mutex<WebsocketManager>>,
) -> Result<WebsocketConnection> { ) -> Result<WebsocketConnection> {
let unrendered_request = app_handle.db().get_websocket_request(request_id)?; let unrendered_request = app_handle.db().get_websocket_request(request_id)?;
let environment = match environment_id { let environment_chain = app_handle.db().resolve_environments(
Some(id) => Some(app_handle.db().get_environment(id)?), &unrendered_request.workspace_id,
None => None, unrendered_request.folder_id.as_deref(),
}; environment_id,
let base_environment = )?;
app_handle.db().get_base_environment(&unrendered_request.workspace_id)?;
let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?; let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?;
let (resolved_request, auth_context_id) = let (resolved_request, auth_context_id) =
resolve_websocket_request(&window, &unrendered_request)?; resolve_websocket_request(&window, &unrendered_request)?;
let request = render_websocket_request( let request = render_websocket_request(
&resolved_request, &resolved_request,
&base_environment, environment_chain,
environment.as_ref(),
&PluginTemplateCallback::new( &PluginTemplateCallback::new(
&app_handle, &app_handle,
&PluginWindowContext::new(&window), &PluginWindowContext::new(&window),
@@ -305,7 +297,7 @@ pub(crate) async fn connect<R: Runtime>(
// Add cookies to WS HTTP Upgrade // Add cookies to WS HTTP Upgrade
if let Some(id) = cookie_jar_id { if let Some(id) = cookie_jar_id {
let cookie_jar = app_handle.db().get_cookie_jar(id)?; let cookie_jar = app_handle.db().get_cookie_jar(&id)?;
let cookies = cookie_jar let cookies = cookie_jar
.cookies .cookies

View File

@@ -6,11 +6,10 @@ use yaak_templates::{parse_and_render, render_json_value_raw, TemplateCallback};
pub async fn render_websocket_request<T: TemplateCallback>( pub async fn render_websocket_request<T: TemplateCallback>(
r: &WebsocketRequest, r: &WebsocketRequest,
base_environment: &Environment, environment_chain: Vec<Environment>,
environment: Option<&Environment>,
cb: &T, cb: &T,
) -> Result<WebsocketRequest> { ) -> Result<WebsocketRequest> {
let vars = &make_vars_hashmap(base_environment, environment); let vars = &make_vars_hashmap(environment_chain);
let mut headers = Vec::new(); let mut headers = Vec::new();
for p in r.headers.clone() { for p in r.headers.clone() {

View File

@@ -37,7 +37,8 @@ export const createEnvironmentAndActivate = createFastMutation<
name, name,
variables: [], variables: [],
workspaceId, workspaceId,
base: false, parentId: baseEnvironment.id,
parentModel: 'environment',
}); });
}, },
onSuccess: async (environmentId) => { onSuccess: async (environmentId) => {

View File

@@ -27,7 +27,8 @@ import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { createRequestAndNavigate } from '../lib/createRequestAndNavigate'; import { createRequestAndNavigate } from '../lib/createRequestAndNavigate';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm'; import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { showDialog, toggleDialog } from '../lib/dialog'; import { showDialog } from '../lib/dialog';
import { editEnvironment } from '../lib/editEnvironment';
import { renameModelWithPrompt } from '../lib/renameModelWithPrompt'; import { renameModelWithPrompt } from '../lib/renameModelWithPrompt';
import { resolvedModelNameWithFolders } from '../lib/resolvedModelName'; import { resolvedModelNameWithFolders } from '../lib/resolvedModelName';
import { router } from '../lib/router'; import { router } from '../lib/router';
@@ -40,7 +41,6 @@ import { HttpMethodTag } from './core/HttpMethodTag';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { PlainInput } from './core/PlainInput'; import { PlainInput } from './core/PlainInput';
import { HStack } from './core/Stacks'; import { HStack } from './core/Stacks';
import { EnvironmentEditDialog } from './EnvironmentEditDialog';
interface CommandPaletteGroup { interface CommandPaletteGroup {
key: string; key: string;
@@ -125,15 +125,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
key: 'environment.edit', key: 'environment.edit',
label: 'Edit Environment', label: 'Edit Environment',
action: 'environmentEditor.toggle', action: 'environmentEditor.toggle',
onSelect: () => { onSelect: () => editEnvironment(activeEnvironment),
toggleDialog({
id: 'environment-editor',
noPadding: true,
size: 'lg',
className: 'h-[80vh]',
render: () => <EnvironmentEditDialog initialEnvironment={activeEnvironment} />,
});
},
}, },
{ {
key: 'environment.create', key: 'environment.create',

View File

@@ -51,7 +51,7 @@ export function ConfirmLargeResponse({ children, response }: Props) {
color="secondary" color="secondary"
variant="border" variant="border"
size="xs" size="xs"
text={() => getResponseBodyText(response)} text={() => getResponseBodyText({ responseId: response.id, filter: null })}
/> />
)} )}
</HStack> </HStack>

View File

@@ -1,16 +1,15 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { memo, useCallback, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment'; import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown'; import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { toggleDialog } from '../lib/dialog'; import { editEnvironment } from '../lib/editEnvironment';
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams'; import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
import { Button } from './core/Button';
import type { ButtonProps } from './core/Button'; import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown'; import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown'; import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator'; import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
import { EnvironmentEditDialog } from './EnvironmentEditDialog';
type Props = { type Props = {
className?: string; className?: string;
@@ -23,16 +22,6 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
const { subEnvironments, baseEnvironment } = useEnvironmentsBreakdown(); const { subEnvironments, baseEnvironment } = useEnvironmentsBreakdown();
const activeEnvironment = useActiveEnvironment(); const activeEnvironment = useActiveEnvironment();
const showEnvironmentDialog = useCallback(() => {
toggleDialog({
id: 'environment-editor',
noPadding: true,
size: 'lg',
className: 'h-[80vh]',
render: () => <EnvironmentEditDialog initialEnvironment={activeEnvironment} />,
});
}, [activeEnvironment]);
const items: DropdownItem[] = useMemo( const items: DropdownItem[] = useMemo(
() => [ () => [
...subEnvironments.map( ...subEnvironments.map(
@@ -55,14 +44,13 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
? [{ type: 'separator', label: 'Environments' }] ? [{ type: 'separator', label: 'Environments' }]
: []) as DropdownItem[]), : []) as DropdownItem[]),
{ {
key: 'edit',
label: 'Manage Environments', label: 'Manage Environments',
hotKeyAction: 'environmentEditor.toggle', hotKeyAction: 'environmentEditor.toggle',
leftSlot: <Icon icon="box" />, leftSlot: <Icon icon="box" />,
onSelect: showEnvironmentDialog, onSelect: () => editEnvironment(activeEnvironment),
}, },
], ],
[activeEnvironment?.id, subEnvironments, showEnvironmentDialog], [subEnvironments, activeEnvironment],
); );
const hasBaseVars = const hasBaseVars =
@@ -79,7 +67,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
)} )}
// If no environments, the button simply opens the dialog. // If no environments, the button simply opens the dialog.
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items // NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
onClick={subEnvironments.length === 0 ? showEnvironmentDialog : undefined} onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
{...buttonProps} {...buttonProps}
> >
<EnvironmentColorIndicator environment={activeEnvironment ?? null} /> <EnvironmentColorIndicator environment={activeEnvironment ?? null} />

View File

@@ -1,42 +1,28 @@
import type { Environment } from '@yaakapp-internal/models'; import type { Environment } from '@yaakapp-internal/models';
import { duplicateModel, patchModel } from '@yaakapp-internal/models'; import { duplicateModel, patchModel } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { createEnvironmentAndActivate } from '../commands/createEnvironment'; import { createEnvironmentAndActivate } from '../commands/createEnvironment';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown'; import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useIsEncryptionEnabled } from '../hooks/useIsEncryptionEnabled';
import { useKeyValue } from '../hooks/useKeyValue';
import { useRandomKey } from '../hooks/useRandomKey';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm'; import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { analyzeTemplate, convertTemplateToSecure } from '../lib/encryption'; import { isBaseEnvironment } from '../lib/model_util';
import { showPrompt } from '../lib/prompt'; import { showPrompt } from '../lib/prompt';
import { resolvedModelName } from '../lib/resolvedModelName'; import { resolvedModelName } from '../lib/resolvedModelName';
import {
setupOrConfigureEncryption,
withEncryptionEnabled,
} from '../lib/setupOrConfigureEncryption';
import { showColorPicker } from '../lib/showColorPicker'; import { showColorPicker } from '../lib/showColorPicker';
import { BadgeButton } from './core/BadgeButton';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { DismissibleBanner } from './core/DismissibleBanner';
import type { DropdownItem } from './core/Dropdown'; import type { DropdownItem } from './core/Dropdown';
import { ContextMenu } from './core/Dropdown'; import { ContextMenu } from './core/Dropdown';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { Heading } from './core/Heading';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { IconTooltip } from './core/IconTooltip'; import { IconTooltip } from './core/IconTooltip';
import { InlineCode } from './core/InlineCode'; import { InlineCode } from './core/InlineCode';
import type { PairWithId } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { Separator } from './core/Separator'; import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout'; import { SplitLayout } from './core/SplitLayout';
import { VStack } from './core/Stacks';
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator'; import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
import { EnvironmentEditor } from './EnvironmentEditor';
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
interface Props { interface Props {
initialEnvironment: Environment | null; initialEnvironment: Environment | null;
@@ -97,13 +83,13 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
<aside className="w-full min-w-0 pt-2"> <aside className="w-full min-w-0 pt-2">
<div className="min-w-0 h-full overflow-y-auto pt-1"> <div className="min-w-0 h-full overflow-y-auto pt-1">
{[baseEnvironment, ...otherBaseEnvironments].map((e) => ( {[baseEnvironment, ...otherBaseEnvironments].map((e) => (
<SidebarButton <EnvironmentDialogSidebarButton
key={e.id} key={e.id}
active={selectedEnvironment?.id == e.id} active={selectedEnvironment?.id == e.id}
onClick={() => setSelectedEnvironmentId(e.id)} onClick={() => setSelectedEnvironmentId(e.id)}
environment={e} environment={e}
duplicateEnvironment={handleDuplicateEnvironment} duplicateEnvironment={handleDuplicateEnvironment}
// Allow deleting base environment if there are multiples // Allow deleting the base environment if there are multiples
deleteEnvironment={ deleteEnvironment={
otherBaseEnvironments.length > 0 ? handleDeleteEnvironment : null otherBaseEnvironments.length > 0 ? handleDeleteEnvironment : null
} }
@@ -121,7 +107,7 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
} }
> >
{resolvedModelName(e)} {resolvedModelName(e)}
</SidebarButton> </EnvironmentDialogSidebarButton>
))} ))}
{subEnvironments.length > 0 && ( {subEnvironments.length > 0 && (
<div className="px-2"> <div className="px-2">
@@ -129,7 +115,7 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
</div> </div>
)} )}
{subEnvironments.map((e) => ( {subEnvironments.map((e) => (
<SidebarButton <EnvironmentDialogSidebarButton
key={e.id} key={e.id}
active={selectedEnvironment?.id === e.id} active={selectedEnvironment?.id === e.id}
environment={e} environment={e}
@@ -139,7 +125,7 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
deleteEnvironment={handleDeleteEnvironment} deleteEnvironment={handleDeleteEnvironment}
> >
{e.name} {e.name}
</SidebarButton> </EnvironmentDialogSidebarButton>
))} ))}
</div> </div>
</aside> </aside>
@@ -153,7 +139,7 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
</div> </div>
) : ( ) : (
<EnvironmentEditor <EnvironmentEditor
className="pt-2 border-l border-border-subtle" className="pl-4 pt-3 border-l border-border-subtle"
environment={selectedEnvironment} environment={selectedEnvironment}
/> />
) )
@@ -162,139 +148,7 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
); );
}; };
const EnvironmentEditor = function ({ function EnvironmentDialogSidebarButton({
environment: selectedEnvironment,
className,
}: {
environment: Environment;
className?: string;
}) {
const workspaceId = selectedEnvironment.workspaceId;
const isEncryptionEnabled = useIsEncryptionEnabled();
const valueVisibility = useKeyValue<boolean>({
namespace: 'global',
key: ['environmentValueVisibility', workspaceId],
fallback: false,
});
const { allEnvironments } = useEnvironmentsBreakdown();
const handleChange = useCallback(
(variables: PairWithId[]) => patchModel(selectedEnvironment, { variables }),
[selectedEnvironment],
);
const [forceUpdateKey, regenerateForceUpdateKey] = useRandomKey();
// Gather a list of env names from other environments to help the user get them aligned
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
const options: GenericCompletionOption[] = [];
if (selectedEnvironment.base) {
return { options };
}
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 === selectedEnvironment.id);
if (isAlreadyInActive) continue;
options.push({
label: name,
type: 'constant',
detail: containingEnvs.map((e) => e.name).join(', '),
});
}
return { options };
}, [selectedEnvironment.base, selectedEnvironment.id, allEnvironments]);
const validateName = useCallback((name: string) => {
// Empty just means the variable doesn't have a name yet and is unusable
if (name === '') return true;
return name.match(/^[a-z_][a-z0-9_-]*$/i) != null;
}, []);
const valueType = !isEncryptionEnabled && valueVisibility.value ? 'text' : 'password';
const promptToEncrypt = useMemo(() => {
if (!isEncryptionEnabled) {
return true;
} else {
return !selectedEnvironment.variables.every(
(v) => v.value === '' || analyzeTemplate(v.value) !== 'insecure',
);
}
}, [selectedEnvironment.variables, isEncryptionEnabled]);
const encryptEnvironment = (environment: Environment) => {
withEncryptionEnabled(async () => {
const encryptedVariables: PairWithId[] = [];
for (const variable of environment.variables) {
const value = variable.value ? await convertTemplateToSecure(variable.value) : '';
encryptedVariables.push(ensurePairId({ ...variable, value }));
}
await handleChange(encryptedVariables);
regenerateForceUpdateKey();
});
};
return (
<VStack space={4} className={classNames(className, 'pl-4')}>
<Heading className="w-full flex items-center gap-0.5">
<EnvironmentColorIndicator clickToEdit environment={selectedEnvironment ?? null} />
<div className="mr-2">{selectedEnvironment?.name}</div>
{isEncryptionEnabled ? (
promptToEncrypt ? (
<BadgeButton color="notice" onClick={() => encryptEnvironment(selectedEnvironment)}>
Encrypt All Variables
</BadgeButton>
) : (
<BadgeButton color="secondary" onClick={setupOrConfigureEncryption}>
Encryption Settings
</BadgeButton>
)
) : (
<>
<BadgeButton color="secondary" onClick={() => valueVisibility.set((v) => !v)}>
{valueVisibility.value ? 'Hide Values' : 'Show Values'}
</BadgeButton>
</>
)}
</Heading>
{selectedEnvironment.public && promptToEncrypt && (
<DismissibleBanner
id={`warn-unencrypted-${selectedEnvironment.id}`}
color="notice"
className="mr-3"
>
This environment is sharable. Ensure variable values are encrypted to avoid accidental
leaking of secrets during directory sync or data export.
</DismissibleBanner>
)}
<div className="h-full pr-2 pb-2 grid grid-rows-[minmax(0,1fr)] overflow-auto">
<PairOrBulkEditor
allowMultilineValues
preferenceName="environment"
nameAutocomplete={nameAutocomplete}
namePlaceholder="VAR_NAME"
nameValidate={validateName}
valueType={valueType}
valueAutocompleteVariables
valueAutocompleteFunctions
forceUpdateKey={`${selectedEnvironment.id}::${forceUpdateKey}`}
pairs={selectedEnvironment.variables}
onChange={handleChange}
stateKey={`environment.${selectedEnvironment.id}`}
forcedEnvironmentId={
// Editing the base environment should resolve variables using the active environment.
// Editing a sub environment should resolve variables as if it's the active environment
selectedEnvironment.base ? undefined : selectedEnvironment.id
}
/>
</div>
</VStack>
);
};
function SidebarButton({
children, children,
className, className,
active, active,
@@ -359,7 +213,7 @@ function SidebarButton({
{ {
label: 'Rename', label: 'Rename',
leftSlot: <Icon icon="pencil" />, leftSlot: <Icon icon="pencil" />,
hidden: environment.base, hidden: isBaseEnvironment(environment),
onSelect: async () => { onSelect: async () => {
const name = await showPrompt({ const name = await showPrompt({
id: 'rename-environment', id: 'rename-environment',
@@ -392,23 +246,13 @@ function SidebarButton({
{ {
label: environment.color ? 'Change Color' : 'Assign Color', label: environment.color ? 'Change Color' : 'Assign Color',
leftSlot: <Icon icon="palette" />, leftSlot: <Icon icon="palette" />,
hidden: environment.base, hidden: isBaseEnvironment(environment),
onSelect: async () => showColorPicker(environment), onSelect: async () => showColorPicker(environment),
}, },
{ {
label: `Make ${environment.public ? 'Private' : 'Sharable'}`, label: `Make ${environment.public ? 'Private' : 'Sharable'}`,
leftSlot: <Icon icon={environment.public ? 'eye_closed' : 'eye'} />, leftSlot: <Icon icon={environment.public ? 'eye_closed' : 'eye'} />,
rightSlot: ( rightSlot: <EnvironmentSharableTooltip />,
<IconTooltip
content={
<>
Sharable environments will be included in Directory Sync or data export. It is
recommended to encrypt all variable values within sharable environments to
prevent accidentally leaking secrets.
</>
}
/>
),
onSelect: async () => { onSelect: async () => {
await patchModel(environment, { public: !environment.public }); await patchModel(environment, { public: !environment.public });
}, },

View File

@@ -0,0 +1,169 @@
import type { Environment } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import React, { useCallback, useMemo } from 'react';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useIsEncryptionEnabled } from '../hooks/useIsEncryptionEnabled';
import { useKeyValue } from '../hooks/useKeyValue';
import { useRandomKey } from '../hooks/useRandomKey';
import { analyzeTemplate, convertTemplateToSecure } from '../lib/encryption';
import { isBaseEnvironment } from '../lib/model_util';
import {
setupOrConfigureEncryption,
withEncryptionEnabled,
} from '../lib/setupOrConfigureEncryption';
import { BadgeButton } from './core/BadgeButton';
import { DismissibleBanner } from './core/DismissibleBanner';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { Heading } from './core/Heading';
import type { PairWithId } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { VStack } from './core/Stacks';
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
export function EnvironmentEditor({
environment: selectedEnvironment,
hideName,
className,
}: {
environment: Environment;
hideName?: boolean;
className?: string;
}) {
const workspaceId = selectedEnvironment.workspaceId;
const isEncryptionEnabled = useIsEncryptionEnabled();
const valueVisibility = useKeyValue<boolean>({
namespace: 'global',
key: ['environmentValueVisibility', workspaceId],
fallback: false,
});
const { allEnvironments } = useEnvironmentsBreakdown();
const handleChange = useCallback(
(variables: PairWithId[]) => patchModel(selectedEnvironment, { variables }),
[selectedEnvironment],
);
const [forceUpdateKey, regenerateForceUpdateKey] = useRandomKey();
// Gather a list of env names from other environments to help the user get them aligned
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
const options: GenericCompletionOption[] = [];
if (isBaseEnvironment(selectedEnvironment)) {
return { options };
}
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 === selectedEnvironment.id);
if (isAlreadyInActive) continue;
options.push({
label: name,
type: 'constant',
detail: containingEnvs.map((e) => e.name).join(', '),
});
}
return { options };
}, [selectedEnvironment, allEnvironments]);
const validateName = useCallback((name: string) => {
// Empty just means the variable doesn't have a name yet and is unusable
if (name === '') return true;
return name.match(/^[a-z_][a-z0-9_-]*$/i) != null;
}, []);
const valueType = !isEncryptionEnabled && valueVisibility.value ? 'text' : 'password';
const allVariableAreEncrypted = useMemo(
() =>
selectedEnvironment.variables.every(
(v) => v.value === '' || analyzeTemplate(v.value) !== 'insecure',
),
[selectedEnvironment.variables],
);
const encryptEnvironment = (environment: Environment) => {
withEncryptionEnabled(async () => {
const encryptedVariables: PairWithId[] = [];
for (const variable of environment.variables) {
const value = variable.value ? await convertTemplateToSecure(variable.value) : '';
encryptedVariables.push(ensurePairId({ ...variable, value }));
}
await handleChange(encryptedVariables);
regenerateForceUpdateKey();
});
};
return (
<VStack space={4} className={className}>
<Heading className="w-full flex items-center gap-0.5">
<EnvironmentColorIndicator clickToEdit environment={selectedEnvironment ?? null} />
{!hideName && <div className="mr-2">{selectedEnvironment?.name}</div>}
{isEncryptionEnabled ? (
!allVariableAreEncrypted ? (
<BadgeButton color="notice" onClick={() => encryptEnvironment(selectedEnvironment)}>
Encrypt All Variables
</BadgeButton>
) : (
<BadgeButton color="secondary" onClick={setupOrConfigureEncryption}>
Encryption Settings
</BadgeButton>
)
) : (
<BadgeButton color="secondary" onClick={() => valueVisibility.set((v) => !v)}>
{valueVisibility.value ? 'Hide Values' : 'Show Values'}
</BadgeButton>
)}
<BadgeButton
color="secondary"
rightSlot={<EnvironmentSharableTooltip />}
onClick={async () => {
await patchModel(selectedEnvironment, { public: !selectedEnvironment.public });
}}
>
{selectedEnvironment.public ? 'Sharable' : 'Private'}
</BadgeButton>
</Heading>
{selectedEnvironment.public && (!isEncryptionEnabled || !allVariableAreEncrypted) && (
<DismissibleBanner
id={`warn-unencrypted-${selectedEnvironment.id}`}
color="notice"
className="mr-3"
actions={[
{
label: 'Encrypt Variables',
onClick: () => encryptEnvironment(selectedEnvironment),
color: 'primary',
},
]}
>
This sharable environment contains plain-text secrets
</DismissibleBanner>
)}
<div className="h-full pr-2 pb-2 grid grid-rows-[minmax(0,1fr)] overflow-auto">
<PairOrBulkEditor
allowMultilineValues
preferenceName="environment"
nameAutocomplete={nameAutocomplete}
namePlaceholder="VAR_NAME"
nameValidate={validateName}
valueType={valueType}
valueAutocompleteVariables
valueAutocompleteFunctions
forceUpdateKey={`${selectedEnvironment.id}::${forceUpdateKey}`}
pairs={selectedEnvironment.variables}
onChange={handleChange}
stateKey={`environment.${selectedEnvironment.id}`}
forcedEnvironmentId={
// Editing the base environment should resolve variables using the active environment.
// Editing a sub environment should resolve variables as if it's the active environment
isBaseEnvironment(selectedEnvironment) ? undefined : selectedEnvironment.id
}
/>
</div>
</VStack>
);
}

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { IconTooltip } from './core/IconTooltip';
export function EnvironmentSharableTooltip() {
return (
<IconTooltip content="Sharable environments are included in Directory Sync and data export." />
);
}

View File

@@ -1,13 +1,19 @@
import { foldersAtom, patchModel } from '@yaakapp-internal/models'; import { createWorkspaceModel, foldersAtom, patchModel } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useAuthTab } from '../hooks/useAuthTab'; import { useAuthTab } from '../hooks/useAuthTab';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useHeadersTab } from '../hooks/useHeadersTab'; import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
import { Input } from './core/Input'; import { Input } from './core/Input';
import { Link } from './core/Link';
import { VStack } from './core/Stacks'; import { VStack } from './core/Stacks';
import type { TabItem } from './core/Tabs/Tabs'; import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs'; import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { EnvironmentEditor } from './EnvironmentEditor';
import { HeadersEditor } from './HeadersEditor'; import { HeadersEditor } from './HeadersEditor';
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor'; import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
import { MarkdownEditor } from './MarkdownEditor'; import { MarkdownEditor } from './MarkdownEditor';
@@ -19,9 +25,10 @@ interface Props {
const TAB_AUTH = 'auth'; const TAB_AUTH = 'auth';
const TAB_HEADERS = 'headers'; const TAB_HEADERS = 'headers';
const TAB_VARIABLES = 'variables';
const TAB_GENERAL = 'general'; const TAB_GENERAL = 'general';
export type FolderSettingsTab = typeof TAB_AUTH | typeof TAB_HEADERS | typeof TAB_GENERAL; export type FolderSettingsTab = typeof TAB_AUTH | typeof TAB_HEADERS | typeof TAB_GENERAL | typeof TAB_VARIABLES;
export function FolderSettingsDialog({ folderId, tab }: Props) { export function FolderSettingsDialog({ folderId, tab }: Props) {
const folders = useAtomValue(foldersAtom); const folders = useAtomValue(foldersAtom);
@@ -30,6 +37,11 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
const authTab = useAuthTab(TAB_AUTH, folder); const authTab = useAuthTab(TAB_AUTH, folder);
const headersTab = useHeadersTab(TAB_HEADERS, folder); const headersTab = useHeadersTab(TAB_HEADERS, folder);
const inheritedHeaders = useInheritedHeaders(folder); const inheritedHeaders = useInheritedHeaders(folder);
const environments = useEnvironmentsBreakdown();
const folderEnvironment = environments.allEnvironments.find(
(e) => e.parentModel === 'folder' && e.parentId === folderId,
);
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
const tabs = useMemo<TabItem[]>(() => { const tabs = useMemo<TabItem[]>(() => {
if (folder == null) return []; if (folder == null) return [];
@@ -39,10 +51,15 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
value: TAB_GENERAL, value: TAB_GENERAL,
label: 'General', label: 'General',
}, },
...authTab,
...headersTab, ...headersTab,
...authTab,
{
value: TAB_VARIABLES,
label: 'Variables',
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
},
]; ];
}, [authTab, folder, headersTab]); }, [authTab, folder, headersTab, numVars]);
if (folder == null) return null; if (folder == null) return null;
@@ -85,6 +102,38 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
stateKey={`headers.${folder.id}`} stateKey={`headers.${folder.id}`}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_VARIABLES} className="pt-3 overflow-y-auto h-full px-4">
{folderEnvironment == null ? (
<EmptyStateText>
<VStack alignItems="center" space={1.5}>
<p>
Override{' '}
<Link href="https://feedback.yaak.app/help/articles/3284139-environments-and-variables">
Variables
</Link>{' '}
for requests within this folder.
</p>
<Button
variant="border"
size="sm"
onClick={async () => {
await createWorkspaceModel({
workspaceId: folder.workspaceId,
parentModel: 'folder',
parentId: folder.id,
model: 'environment',
name: 'Folder Environment',
});
}}
>
Create Folder Environment
</Button>
</VStack>
</EmptyStateText>
) : (
<EnvironmentEditor hideName environment={folderEnvironment} />
)}
</TabContent>
</Tabs> </Tabs>
); );
} }

View File

@@ -32,7 +32,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
const authConfig = useHttpAuthenticationConfig( const authConfig = useHttpAuthenticationConfig(
model.authenticationType, model.authenticationType,
model.authentication, model.authentication,
model.id, model,
); );
const handleChange = useCallback( const handleChange = useCallback(

View File

@@ -75,8 +75,8 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
value: TAB_DATA, value: TAB_DATA,
label: 'Directory Sync', label: 'Directory Sync',
}, },
...authTab,
...headersTab, ...headersTab,
...authTab,
]} ]}
> >
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4"> <TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">

View File

@@ -1,15 +1,18 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames'; import classNames from 'classnames';
import { useKeyValue } from '../../hooks/useKeyValue'; import { useKeyValue } from '../../hooks/useKeyValue';
import type { BannerProps } from './Banner'; import type { BannerProps } from './Banner';
import { Banner } from './Banner'; import { Banner } from './Banner';
import { Button } from './Button'; import { Button } from './Button';
import { HStack } from './Stacks';
export function DismissibleBanner({ export function DismissibleBanner({
children, children,
className, className,
id, id,
actions,
...props ...props
}: BannerProps & { id: string }) { }: BannerProps & { id: string; actions?: { label: string; onClick: () => void; color?: Color }[] }) {
const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({ const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({
namespace: 'global', namespace: 'global',
key: ['dismiss-banner', id], key: ['dismiss-banner', id],
@@ -19,17 +22,34 @@ export function DismissibleBanner({
if (dismissed) return null; if (dismissed) return null;
return ( return (
<Banner className={classNames(className, 'relative grid grid-cols-[1fr_auto] gap-3')} {...props}> <Banner
className={classNames(className, 'relative grid grid-cols-[1fr_auto] gap-3')}
{...props}
>
{children} {children}
<Button <HStack space={1.5}>
variant="border" {actions?.map((a, i) => (
color={props.color} <Button
size="xs" key={a.label + i}
onClick={() => setDismissed((d) => !d)} variant="border"
title="Dismiss message" color={a.color ?? props.color}
> size="xs"
Dismiss onClick={a.onClick}
</Button> title="Dismiss message"
>
{a.label}
</Button>
))}
<Button
variant="border"
color={props.color}
size="xs"
onClick={() => setDismissed((d) => !d)}
title="Dismiss message"
>
Dismiss
</Button>
</HStack>
</Banner> </Banner>
); );
} }

View File

@@ -519,7 +519,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
className={classNames( className={classNames(
className, className,
'x-theme-menu', 'x-theme-menu',
'outline-none my-1 pointer-events-auto fixed z-50', 'outline-none my-1 pointer-events-auto fixed z-40',
)} )}
> >
{showTriangle && ( {showTriangle && (

View File

@@ -7,7 +7,7 @@ import { emacs } from '@replit/codemirror-emacs';
import { vim } from '@replit/codemirror-vim'; import { vim } from '@replit/codemirror-vim';
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap'; import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
import type { EditorKeymap, EnvironmentVariable } from '@yaakapp-internal/models'; import type { EditorKeymap } from '@yaakapp-internal/models';
import { settingsAtom } from '@yaakapp-internal/models'; import { settingsAtom } from '@yaakapp-internal/models';
import type { EditorLanguage, TemplateFunction } from '@yaakapp-internal/plugins'; import type { EditorLanguage, TemplateFunction } from '@yaakapp-internal/plugins';
import { parseTemplate } from '@yaakapp-internal/templates'; import { parseTemplate } from '@yaakapp-internal/templates';
@@ -28,10 +28,12 @@ import {
useRef, useRef,
} from 'react'; } from 'react';
import { activeEnvironmentIdAtom } from '../../../hooks/useActiveEnvironment'; import { activeEnvironmentIdAtom } from '../../../hooks/useActiveEnvironment';
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables'; import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables';
import { useRequestEditor } from '../../../hooks/useRequestEditor'; import { useRequestEditor } from '../../../hooks/useRequestEditor';
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions'; import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
import { showDialog } from '../../../lib/dialog'; import { showDialog } from '../../../lib/dialog';
import { editEnvironment } from '../../../lib/editEnvironment';
import { tryFormatJson, tryFormatXml } from '../../../lib/formatters'; import { tryFormatJson, tryFormatXml } from '../../../lib/formatters';
import { withEncryptionEnabled } from '../../../lib/setupOrConfigureEncryption'; import { withEncryptionEnabled } from '../../../lib/setupOrConfigureEncryption';
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog'; import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
@@ -96,7 +98,7 @@ export interface EditorProps {
const stateFields = { history: historyField, folds: foldState }; const stateFields = { history: historyField, folds: foldState };
const emptyVariables: EnvironmentVariable[] = []; const emptyVariables: WrappedEnvironmentVariable[] = [];
const emptyExtension: Extension = []; const emptyExtension: Extension = [];
export const Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor( export const Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
@@ -306,24 +308,9 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
); );
const onClickVariable = useCallback( const onClickVariable = useCallback(
async (_v: EnvironmentVariable, tagValue: string, startPos: number) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars
const initialTokens = parseTemplate(tagValue); async (v: WrappedEnvironmentVariable, _tagValue: string, _startPos: number) => {
showDialog({ editEnvironment(v.environment);
size: 'dynamic',
id: 'template-variable',
title: 'Change Variable',
render: ({ hide }) => (
<TemplateVariableDialog
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
cm.current?.view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
),
});
}, },
[], [],
); );

View File

@@ -35,10 +35,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-internal/models';
import { graphql } from 'cm6-graphql'; import { graphql } from 'cm6-graphql';
import type { GraphQLSchema } from 'graphql'; import type { GraphQLSchema } from 'graphql';
import { activeRequestIdAtom } from '../../../hooks/useActiveRequestId'; import { activeRequestIdAtom } from '../../../hooks/useActiveRequestId';
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
import { jotaiStore } from '../../../lib/jotai'; import { jotaiStore } from '../../../lib/jotai';
import { renderMarkdown } from '../../../lib/markdown'; import { renderMarkdown } from '../../../lib/markdown';
import { pluralizeCount } from '../../../lib/pluralize'; import { pluralizeCount } from '../../../lib/pluralize';
@@ -110,8 +110,8 @@ export function getLanguageExtension({
graphQLSchema, graphQLSchema,
}: { }: {
useTemplating: boolean; useTemplating: boolean;
environmentVariables: EnvironmentVariable[]; environmentVariables: WrappedEnvironmentVariable[];
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void; onClickVariable: (option: WrappedEnvironmentVariable, tagValue: string, startPos: number) => void;
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void; onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
onClickPathParameter: (name: string) => void; onClickPathParameter: (name: string) => void;
completionOptions: TwigCompletionOption[]; completionOptions: TwigCompletionOption[];

View File

@@ -2,7 +2,7 @@ import type { LanguageSupport } from '@codemirror/language';
import { LRLanguage } from '@codemirror/language'; import { LRLanguage } from '@codemirror/language';
import type { Extension } from '@codemirror/state'; import type { Extension } from '@codemirror/state';
import { parseMixed } from '@lezer/common'; import { parseMixed } from '@lezer/common';
import type { EnvironmentVariable } from '@yaakapp-internal/models'; import type { WrappedEnvironmentVariable } from '../../../../hooks/useEnvironmentVariables';
import type { GenericCompletionConfig } from '../genericCompletion'; import type { GenericCompletionConfig } from '../genericCompletion';
import { genericCompletion } from '../genericCompletion'; import { genericCompletion } from '../genericCompletion';
import { textLanguage } from '../text/extension'; import { textLanguage } from '../text/extension';
@@ -21,10 +21,10 @@ export function twig({
extraExtensions, extraExtensions,
}: { }: {
base: LanguageSupport; base: LanguageSupport;
environmentVariables: EnvironmentVariable[]; environmentVariables: WrappedEnvironmentVariable[];
completionOptions: TwigCompletionOption[]; completionOptions: TwigCompletionOption[];
autocomplete?: GenericCompletionConfig; autocomplete?: GenericCompletionConfig;
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void; onClickVariable: (option: WrappedEnvironmentVariable, tagValue: string, startPos: number) => void;
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void; onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
onClickPathParameter: (name: string) => void; onClickPathParameter: (name: string) => void;
extraExtensions: Extension[]; extraExtensions: Extension[];
@@ -33,9 +33,11 @@ export function twig({
const variableOptions: TwigCompletionOption[] = const variableOptions: TwigCompletionOption[] =
environmentVariables.map((v) => ({ environmentVariables.map((v) => ({
...v, name: v.variable.name,
value: v.variable.value,
type: 'variable', type: 'variable',
label: v.name, label: v.variable.name,
description: `Inherited from ${v.source}`,
onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos), onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos),
})) ?? []; })) ?? [];

View File

@@ -94,13 +94,16 @@ function templateTags(
(o) => o.name === name || (o.type === 'function' && o.aliases?.includes(name)), (o) => o.name === name || (o.type === 'function' && o.aliases?.includes(name)),
); );
if (option == null) { if (option == null) {
const from = node.from; // Cache here so the reference doesn't change
option = { option = {
invalid: true, invalid: true,
type: 'variable', type: 'variable',
name: inner, name: inner,
value: null, value: null,
label: inner, label: inner,
onClick: () => onClickMissingVariable(name, rawTag, node.from), onClick: () => {
onClickMissingVariable(name, rawTag, from);
},
}; };
} }

View File

@@ -10,7 +10,7 @@ interface Props {
} }
export function CsvViewer({ response, className }: Props) { export function CsvViewer({ response, className }: Props) {
const body = useResponseBodyText(response); const body = useResponseBodyText({ response, filter: null });
const parsed = useMemo(() => { const parsed = useMemo(() => {
if (body.data == null) return null; if (body.data == null) return null;

View File

@@ -13,7 +13,7 @@ interface Props {
} }
export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) { export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) {
const rawTextBody = useResponseBodyText({ responseId: response.id, filter: null }); const rawTextBody = useResponseBodyText({ response, filter: null });
const contentType = getContentTypeFromHeaders(response.headers); const contentType = getContentTypeFromHeaders(response.headers);
const language = languageFromContentType(contentType, rawTextBody.data ?? ''); const language = languageFromContentType(contentType, rawTextBody.data ?? '');
@@ -32,7 +32,7 @@ export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Prop
text={rawTextBody.data} text={rawTextBody.data}
pretty={pretty} pretty={pretty}
className={textViewerClassName} className={textViewerClassName}
responseId={response.id} response={response}
requestId={response.requestId} requestId={response.requestId}
/> />
); );

View File

@@ -9,7 +9,7 @@ interface Props {
} }
export function JsonViewer({ response, className }: Props) { export function JsonViewer({ response, className }: Props) {
const rawBody = useResponseBodyText(response); const rawBody = useResponseBodyText({ response, filter: null });
if (rawBody.isLoading || rawBody.data == null) return null; if (rawBody.isLoading || rawBody.data == null) return null;

View File

@@ -7,7 +7,7 @@ interface Props {
} }
export function SvgViewer({ response }: Props) { export function SvgViewer({ response }: Props) {
const rawTextBody = useResponseBodyText(response); const rawTextBody = useResponseBodyText({ response, filter: null });
const [src, setSrc] = useState<string | null>(null); const [src, setSrc] = useState<string | null>(null);
useEffect(() => { useEffect(() => {

View File

@@ -1,3 +1,4 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
@@ -18,13 +19,13 @@ interface Props {
className?: string; className?: string;
text: string; text: string;
language: EditorProps['language']; language: EditorProps['language'];
responseId: string; response: HttpResponse;
requestId: string; requestId: string;
} }
const useFilterText = createGlobalState<Record<string, string | null>>({}); const useFilterText = createGlobalState<Record<string, string | null>>({});
export function TextViewer({ language, text, responseId, requestId, pretty, className }: Props) { export function TextViewer({ language, text, response, requestId, pretty, className }: Props) {
const [filterTextMap, setFilterTextMap] = useFilterText(); const [filterTextMap, setFilterTextMap] = useFilterText();
const filterText = filterTextMap[requestId] ?? null; const filterText = filterTextMap[requestId] ?? null;
const debouncedFilterText = useDebouncedValue(filterText); const debouncedFilterText = useDebouncedValue(filterText);
@@ -36,7 +37,7 @@ export function TextViewer({ language, text, responseId, requestId, pretty, clas
); );
const isSearching = filterText != null; const isSearching = filterText != null;
const filteredResponse = useResponseBodyText({ responseId, filter: debouncedFilterText ?? null }); const filteredResponse = useResponseBodyText({ response, filter: debouncedFilterText ?? null });
const toggleSearch = useCallback(() => { const toggleSearch = useCallback(() => {
if (isSearching) { if (isSearching) {
@@ -69,7 +70,7 @@ export function TextViewer({ language, text, responseId, requestId, pretty, clas
defaultValue={filterText} defaultValue={filterText}
onKeyDown={(e) => e.key === 'Escape' && toggleSearch()} onKeyDown={(e) => e.key === 'Escape' && toggleSearch()}
onChange={setFilterText} onChange={setFilterText}
stateKey={`filter.${responseId}`} stateKey={`filter.${response.id}`}
/> />
</div>, </div>,
); );
@@ -96,7 +97,7 @@ export function TextViewer({ language, text, responseId, requestId, pretty, clas
isSearching, isSearching,
language, language,
requestId, requestId,
responseId, response,
setFilterText, setFilterText,
toggleSearch, toggleSearch,
]); ]);

View File

@@ -8,7 +8,7 @@ interface Props {
export function WebPageViewer({ response }: Props) { export function WebPageViewer({ response }: Props) {
const { url } = response; const { url } = response;
const body = useResponseBodyText(response).data ?? ''; const body = useResponseBodyText({ response, filter: null }).data ?? '';
const contentForIframe: string | undefined = useMemo(() => { const contentForIframe: string | undefined = useMemo(() => {
if (body.includes('<head>')) { if (body.includes('<head>')) {

View File

@@ -37,11 +37,6 @@ export function SidebarItemContextMenu({ child, show, close }: Props) {
const items = useMemo((): DropdownItem[] => { const items = useMemo((): DropdownItem[] => {
if (child.model === 'folder') { if (child.model === 'folder') {
return [ return [
{
label: 'Send All',
leftSlot: <Icon icon="send_horizontal" />,
onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.id)),
},
{ {
label: 'Settings', label: 'Settings',
leftSlot: <Icon icon="settings" />, leftSlot: <Icon icon="settings" />,
@@ -52,6 +47,11 @@ export function SidebarItemContextMenu({ child, show, close }: Props) {
leftSlot: <Icon icon="copy" />, leftSlot: <Icon icon="copy" />,
onSelect: () => duplicateModelById(child.model, child.id), onSelect: () => duplicateModelById(child.model, child.id),
}, },
{
label: 'Send All',
leftSlot: <Icon icon="send_horizontal" />,
onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.id)),
},
{ {
label: 'Delete', label: 'Delete',
color: 'danger', color: 'danger',

View File

@@ -4,5 +4,5 @@ import { useEnvironmentVariables } from './useEnvironmentVariables';
export function useActiveEnvironmentVariables() { export function useActiveEnvironmentVariables() {
const activeEnvironment = useAtomValue(activeEnvironmentAtom); const activeEnvironment = useAtomValue(activeEnvironmentAtom);
return useEnvironmentVariables(activeEnvironment?.id ?? null); return useEnvironmentVariables(activeEnvironment?.id ?? null).map((v) => v.variable);
} }

View File

@@ -7,7 +7,7 @@ export function useCopyHttpResponse(response: HttpResponse) {
return useFastMutation({ return useFastMutation({
mutationKey: ['copy_http_response', response.id], mutationKey: ['copy_http_response', response.id],
async mutationFn() { async mutationFn() {
const body = await getResponseBodyText(response); const body = await getResponseBodyText({ responseId: response.id, filter: null });
copyToClipboard(body); copyToClipboard(body);
}, },
}); });

View File

@@ -0,0 +1,10 @@
import type { Environment } from '@yaakapp-internal/models';
import { useKeyValue } from './useKeyValue';
export function useEnvironmentValueVisibility(environment: Environment) {
return useKeyValue<boolean>({
namespace: 'global',
key: ['environmentValueVisibility', environment.workspaceId],
fallback: false,
});
}

View File

@@ -1,25 +1,52 @@
import type { EnvironmentVariable } from '@yaakapp-internal/models'; import type { Environment, EnvironmentVariable } from '@yaakapp-internal/models';
import { environmentsAtom } from '@yaakapp-internal/models'; import { foldersAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { jotaiStore } from '../lib/jotai';
import { useActiveRequest } from './useActiveRequest';
import { useEnvironmentsBreakdown } from './useEnvironmentsBreakdown'; import { useEnvironmentsBreakdown } from './useEnvironmentsBreakdown';
import { useParentFolders } from './useParentFolders';
export function useEnvironmentVariables(environmentId: string | null) { export function useEnvironmentVariables(environmentId: string | null) {
const { baseEnvironment } = useEnvironmentsBreakdown(); const { baseEnvironment, folderEnvironments, subEnvironments } = useEnvironmentsBreakdown();
const activeEnvironment = const activeEnvironment = subEnvironments.find((e) => e.id === environmentId) ?? null;
useAtomValue(environmentsAtom).find((e) => e.id === environmentId) ?? null; const activeRequest = useActiveRequest();
const parentFolders = useParentFolders(activeRequest);
return useMemo(() => { return useMemo(() => {
const varMap: Record<string, EnvironmentVariable> = {}; const varMap: Record<string, WrappedEnvironmentVariable> = {};
const folderVariables = parentFolders.flatMap((f) =>
wrapVariables(folderEnvironments.find((fe) => fe.parentId === f.id) ?? null),
);
const allVariables = [ const allVariables = [
...(baseEnvironment?.variables ?? []), ...folderVariables,
...(activeEnvironment?.variables ?? []), ...wrapVariables(activeEnvironment),
...wrapVariables(baseEnvironment),
]; ];
for (const v of allVariables) { for (const v of allVariables) {
if (!v.enabled || !v.name) continue; if (!v.variable.enabled || !v.variable.name || v.variable.name in varMap) {
varMap[v.name] = v; continue;
}
varMap[v.variable.name] = v;
} }
return Object.values(varMap); return Object.values(varMap);
}, [activeEnvironment, baseEnvironment]); }, [activeEnvironment, baseEnvironment, folderEnvironments, parentFolders]);
}
export interface WrappedEnvironmentVariable {
variable: EnvironmentVariable;
environment: Environment;
source: string;
}
function wrapVariables(e: Environment | null): WrappedEnvironmentVariable[] {
if (e == null) return [];
const folders = jotaiStore.get(foldersAtom);
return e.variables.map((v) => {
const folder = e.parentModel === 'folder' ? folders.find((f) => f.id === e.parentId) : null;
const source = folder?.name ?? e.name;
return { variable: v, environment: e, source };
});
} }

View File

@@ -5,12 +5,15 @@ import { useMemo } from 'react';
export function useEnvironmentsBreakdown() { export function useEnvironmentsBreakdown() {
const allEnvironments = useAtomValue(environmentsAtom); const allEnvironments = useAtomValue(environmentsAtom);
return useMemo(() => { return useMemo(() => {
const baseEnvironments = allEnvironments.filter((e) => e.base) ?? []; const baseEnvironments = allEnvironments.filter((e) => e.parentId == null) ?? [];
const subEnvironments = allEnvironments.filter((e) => !e.base) ?? []; const subEnvironments =
allEnvironments.filter((e) => e.parentModel === 'environment' && e.parentId != null) ?? [];
const folderEnvironments =
allEnvironments.filter((e) => e.parentModel === 'folder' && e.parentId != null) ?? [];
const baseEnvironment = baseEnvironments[0] ?? null; const baseEnvironment = baseEnvironments[0] ?? null;
const otherBaseEnvironments = const otherBaseEnvironments =
baseEnvironments.filter((e) => e.id !== baseEnvironment?.id) ?? []; baseEnvironments.filter((e) => e.id !== baseEnvironment?.id) ?? [];
return { allEnvironments, baseEnvironment, subEnvironments, otherBaseEnvironments }; return { allEnvironments, baseEnvironment, subEnvironments, folderEnvironments, otherBaseEnvironments };
}, [allEnvironments]); }, [allEnvironments]);
} }

View File

@@ -40,7 +40,7 @@ const hotkeys: Record<HotkeyAction, string[]> = {
'request_switcher.prev': ['Control+Tab'], 'request_switcher.prev': ['Control+Tab'],
'request_switcher.toggle': ['CmdCtrl+p'], 'request_switcher.toggle': ['CmdCtrl+p'],
'settings.show': ['CmdCtrl+,'], 'settings.show': ['CmdCtrl+,'],
'sidebar.delete_selected_item': ['Backspace'], 'sidebar.delete_selected_item': ['Delete'],
'sidebar.focus': ['CmdCtrl+b'], 'sidebar.focus': ['CmdCtrl+b'],
'url_bar.focus': ['CmdCtrl+l'], 'url_bar.focus': ['CmdCtrl+l'],
'workspace_settings.show': ['CmdCtrl+;'], 'workspace_settings.show': ['CmdCtrl+;'],
@@ -98,7 +98,7 @@ export function useHotKey(
// Don't add key if not holding modifier // Don't add key if not holding modifier
const isValidKeymapKey = const isValidKeymapKey =
e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.key === 'Backspace'; e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.key === 'Backspace' || e.key === 'Delete';
if (!isValidKeymapKey) { if (!isValidKeymapKey) {
return; return;
} }

View File

@@ -18,7 +18,7 @@ import { activeWorkspaceIdAtom } from './useActiveWorkspace';
export function useHttpAuthenticationConfig( export function useHttpAuthenticationConfig(
authName: string | null, authName: string | null,
values: Record<string, JsonPrimitive>, values: Record<string, JsonPrimitive>,
requestId: string, request: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,
) { ) {
const workspaceId = useAtomValue(activeWorkspaceIdAtom); const workspaceId = useAtomValue(activeWorkspaceIdAtom);
const environmentId = useAtomValue(activeEnvironmentIdAtom); const environmentId = useAtomValue(activeEnvironmentIdAtom);
@@ -37,7 +37,7 @@ export function useHttpAuthenticationConfig(
return useQuery({ return useQuery({
queryKey: [ queryKey: [
'http_authentication_config', 'http_authentication_config',
requestId, request,
authName, authName,
values, values,
responseKey, responseKey,
@@ -53,8 +53,7 @@ export function useHttpAuthenticationConfig(
{ {
authName, authName,
values, values,
requestId, request,
workspaceId,
environmentId, environmentId,
}, },
); );
@@ -63,17 +62,16 @@ export function useHttpAuthenticationConfig(
...config, ...config,
actions: config.actions?.map((a, i) => ({ actions: config.actions?.map((a, i) => ({
...a, ...a,
call: async ({ call: async (
id: modelId, model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,
}: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace) => { ) => {
await invokeCmd('cmd_call_http_authentication_action', { await invokeCmd('cmd_call_http_authentication_action', {
pluginRefId: config.pluginRefId, pluginRefId: config.pluginRefId,
actionIndex: i, actionIndex: i,
authName, authName,
values, values,
modelId, model,
environmentId, environmentId,
workspaceId,
}); });
// Ensure the config is refreshed after the action is done // Ensure the config is refreshed after the action is done

View File

@@ -66,7 +66,7 @@ export function useIntrospectGraphQL(
return setError(response.error); return setError(response.error);
} }
const bodyText = await getResponseBodyText(response); const bodyText = await getResponseBodyText({ responseId: response.id, filter: null });
if (response.status < 200 || response.status >= 300) { if (response.status < 200 || response.status >= 300) {
return setError( return setError(
`Request failed with status ${response.status}.\nThe response text is:\n\n${bodyText}`, `Request failed with status ${response.status}.\nThe response text is:\n\n${bodyText}`,

View File

@@ -1,7 +1,7 @@
import {useAtomValue} from "jotai/index"; import { useAtomValue } from 'jotai';
import {activeWorkspaceMetaAtom} from "./useActiveWorkspace"; import { activeWorkspaceMetaAtom } from './useActiveWorkspace';
export function useIsEncryptionEnabled() { export function useIsEncryptionEnabled() {
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom); const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
return workspaceMeta?.encryptionKey != null; return workspaceMeta?.encryptionKey != null;
} }

View File

@@ -0,0 +1,24 @@
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
import { foldersAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
export function useParentFolders(m: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null) {
const folders = useAtomValue(foldersAtom);
return useMemo(() => getParentFolders(folders, m), [folders, m]);
}
function getParentFolders(
folders: Folder[],
currentModel: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null,
): Folder[] {
if (currentModel == null) return [];
const folder = currentModel.folderId ? folders.find((f) => f.id === currentModel.folderId) : null;
if (folder == null) {
return [];
}
return [folder, ...getParentFolders(folders, folder)];
}

View File

@@ -1,15 +1,22 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import type { HttpResponse } from '@yaakapp-internal/models';
import { getResponseBodyText } from '../lib/responseBody'; import { getResponseBodyText } from '../lib/responseBody';
export function useResponseBodyText({ export function useResponseBodyText({
responseId, response,
filter, filter,
}: { }: {
responseId: string; response: HttpResponse;
filter: string | null; filter: string | null;
}) { }) {
return useQuery({ return useQuery({
queryKey: ['response_body_text', responseId, filter ?? ''], queryKey: [
queryFn: () => getResponseBodyText({ responseId, filter }), 'response_body_text',
response.id,
response.updatedAt,
response.contentLength,
filter ?? '',
],
queryFn: () => getResponseBodyText({ responseId: response.id, filter }),
}); });
} }

View File

@@ -0,0 +1,18 @@
import type { Environment } from '@yaakapp-internal/models';
import { openFolderSettings } from '../commands/openFolderSettings';
import { EnvironmentEditDialog } from '../components/EnvironmentEditDialog';
import { toggleDialog } from './dialog';
export function editEnvironment(environment: Environment | null) {
if (environment?.parentModel === 'folder' && environment.parentId != null) {
openFolderSettings(environment.parentId, 'variables');
} else {
toggleDialog({
id: 'environment-editor',
noPadding: true,
size: 'lg',
className: 'h-[80vh]',
render: () => <EnvironmentEditDialog initialEnvironment={environment} />,
});
}
}

View File

@@ -1,4 +1,4 @@
import type { AnyModel, Cookie, HttpResponseHeader } from '@yaakapp-internal/models'; import type { AnyModel, Cookie, Environment, HttpResponseHeader } from '@yaakapp-internal/models';
import { getMimeTypeFromContentType } from './contentType'; import { getMimeTypeFromContentType } from './contentType';
export const BODY_TYPE_NONE = null; export const BODY_TYPE_NONE = null;
@@ -47,3 +47,7 @@ export function getCharsetFromContentType(headers: HttpResponseHeader[]): string
const mimeType = getMimeTypeFromContentType(contentType); const mimeType = getMimeTypeFromContentType(contentType);
return mimeType.parameters.get('charset') ?? null; return mimeType.parameters.get('charset') ?? null;
} }
export function isBaseEnvironment(environment: Environment): boolean {
return environment.parentId == null;
}

View File

@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "vite dev --force", "dev": "vite dev --force",
"build": "vite build", "build": "vite build",
"lint": "eslint . --ext .ts,.tsx" "lint": "tsc --noEmit && eslint . --ext .ts,.tsx"
}, },
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.8.1", "@codemirror/commands": "^6.8.1",