Ability to sync environments to folder (#207)

This commit is contained in:
Gregory Schier
2025-05-08 14:10:07 -07:00
committed by GitHub
parent 77cdea2f9f
commit 94d4227bc1
54 changed files with 710 additions and 425 deletions
@@ -0,0 +1,11 @@
-- There used to be sync code that skipped over environments because we didn't
-- want to sync potentially insecure data. With encryption, it is now possible
-- to sync environments securely. However, there were already sync states in the
-- DB that marked environments as "Synced". Running the sync code on these envs
-- would mark them as deleted by FS (exist in SyncState but not on FS).
--
-- To undo this mess, we have this migration to delete all environment-related
-- sync states so we can sync from a clean slate.
DELETE
FROM sync_states
WHERE model_id LIKE 'ev_%';
@@ -0,0 +1,20 @@
-- Add a public column to represent whether an environment can be shared or exported
ALTER TABLE environments
ADD COLUMN public BOOLEAN DEFAULT FALSE;
-- Add a base column to represent whether an environment is a base or sub environment. We used to
-- do this with environment_id, but we need a more flexible solution now that envs can be optionally
-- synced. E.g., it's now possible to only import a sub environment from a different client without
-- its base environment "parent."
ALTER TABLE environments
ADD COLUMN base BOOLEAN DEFAULT FALSE;
-- SQLite doesn't support dynamic default values, so we update `base` based on the value of
-- environment_id.
UPDATE environments
SET base = TRUE
WHERE environment_id IS NULL;
-- Finally, we drop the old `environment_id` column that will no longer be used
ALTER TABLE environments
DROP COLUMN environment_id;
+2 -3
View File
@@ -831,7 +831,6 @@ async fn cmd_import_data<R: Runtime>(
.map(|mut v| { .map(|mut v| {
v.id = maybe_gen_id::<Environment>(v.id.as_str(), &mut id_map); v.id = maybe_gen_id::<Environment>(v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(v.workspace_id.as_str(), &mut id_map); v.workspace_id = maybe_gen_id::<Workspace>(v.workspace_id.as_str(), &mut id_map);
v.environment_id = maybe_gen_id_opt::<Environment>(v.environment_id, &mut id_map);
v v
}) })
.collect(); .collect();
@@ -985,10 +984,10 @@ async fn cmd_export_data<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
export_path: &str, export_path: &str,
workspace_ids: Vec<&str>, workspace_ids: Vec<&str>,
include_environments: bool, include_private_environments: bool,
) -> YaakResult<()> { ) -> YaakResult<()> {
let export_data = let export_data =
get_workspace_export_resources(&app_handle, workspace_ids, include_environments).await?; get_workspace_export_resources(&app_handle, workspace_ids, include_private_environments)?;
let f = File::options() let f = File::options()
.create(true) .create(true)
.truncate(true) .truncate(true)
+1 -1
View File
@@ -7372,7 +7372,7 @@ function importEnvironment(e, workspaceId) {
createdAt: e.created ? new Date(e.created).toISOString().replace("Z", "") : void 0, createdAt: e.created ? new Date(e.created).toISOString().replace("Z", "") : void 0,
updatedAt: e.updated ? new Date(e.updated).toISOString().replace("Z", "") : void 0, updatedAt: e.updated ? new Date(e.updated).toISOString().replace("Z", "") : void 0,
workspaceId: convertId(workspaceId), workspaceId: convertId(workspaceId),
environmentId: e.parentId === workspaceId ? null : convertId(e.parentId), base: e.parentId === workspaceId ? true : false,
model: "environment", model: "environment",
name: e.name, name: e.name,
variables: Object.entries(e.data).map(([name, value]) => ({ variables: Object.entries(e.data).map(([name, value]) => ({
+6
View File
@@ -69,6 +69,12 @@ function migrateImport(contents) {
} }
} }
} }
for (const environment of parsed.resources.environments ?? []) {
if ("environmentId" in environment) {
environment.base = environment.environmentId == null;
delete environment.environmentId;
}
}
return { resources: parsed.resources }; return { resources: parsed.resources };
} }
function isJSObject(obj) { function isJSObject(obj) {
+1 -1
View File
@@ -38,7 +38,7 @@ pub(crate) fn decrypt_data(cipher_data: &[u8], key: &Key<XChaCha20Poly1305>) ->
let (nonce, ciphered_data) = rest.split_at_checked(nonce_bytes).ok_or(InvalidEncryptedData)?; let (nonce, ciphered_data) = rest.split_at_checked(nonce_bytes).ok_or(InvalidEncryptedData)?;
let cipher = XChaCha20Poly1305::new(&key); let cipher = XChaCha20Poly1305::new(&key);
cipher.decrypt(nonce.into(), ciphered_data).map_err(|_| DecryptionError) cipher.decrypt(nonce.into(), ciphered_data).map_err(|_e| DecryptionError)
} }
#[cfg(test)] #[cfg(test)]
+5 -2
View File
@@ -16,13 +16,16 @@ pub enum Error {
#[error("Incorrect workspace key")] #[error("Incorrect workspace key")]
IncorrectWorkspaceKey, IncorrectWorkspaceKey,
#[error("Failed to decrypt workspace key: {0}")]
WorkspaceKeyDecryptionError(String),
#[error("Crypto IO error: {0}")] #[error("Crypto IO error: {0}")]
IoError(#[from] io::Error), IoError(#[from] io::Error),
#[error("Failed to encrypt")] #[error("Failed to encrypt data")]
EncryptionError, EncryptionError,
#[error("Failed to decrypt")] #[error("Failed to decrypt data")]
DecryptionError, DecryptionError,
#[error("Invalid encrypted data")] #[error("Invalid encrypted data")]
+7 -3
View File
@@ -1,4 +1,6 @@
use crate::error::Error::{GenericError, IncorrectWorkspaceKey, MissingWorkspaceKey}; use crate::error::Error::{
GenericError, IncorrectWorkspaceKey, MissingWorkspaceKey, WorkspaceKeyDecryptionError,
};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::master_key::MasterKey; use crate::master_key::MasterKey;
use crate::workspace_key::WorkspaceKey; use crate::workspace_key::WorkspaceKey;
@@ -149,8 +151,10 @@ impl EncryptionManager {
let mkey = self.get_master_key()?; let mkey = self.get_master_key()?;
let decoded_key = BASE64_STANDARD let decoded_key = BASE64_STANDARD
.decode(key.encrypted_key) .decode(key.encrypted_key)
.map_err(|e| GenericError(format!("Failed to decode workspace key {e:?}")))?; .map_err(|e| WorkspaceKeyDecryptionError(e.to_string()))?;
let raw_key = mkey.decrypt(decoded_key.as_slice())?; let raw_key = mkey
.decrypt(decoded_key.as_slice())
.map_err(|e| WorkspaceKeyDecryptionError(e.to_string()))?;
info!("Got existing workspace key for {workspace_id}"); info!("Got existing workspace key for {workspace_id}");
let wkey = WorkspaceKey::from_raw_key(raw_key.as_slice()); let wkey = WorkspaceKey::from_raw_key(raw_key.as_slice());
+1 -1
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, environmentId: string | null, createdAt: string, updatedAt: string, name: string, variables: Array<EnvironmentVariable>, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
+1 -1
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, environmentId: string | null, createdAt: string, updatedAt: string, name: string, variables: Array<EnvironmentVariable>, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
+1 -1
View File
@@ -109,7 +109,7 @@ pub(crate) fn workspace_models<R: Runtime>(
// Add the workspace children // Add the workspace children
if let Some(wid) = workspace_id { if let Some(wid) = workspace_id {
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect()); l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_environments(wid)?.into_iter().map(Into::into).collect()); l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect()); l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect()); l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect()); l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect());
+6
View File
@@ -21,6 +21,12 @@ pub enum Error {
#[error("Model error: {0}")] #[error("Model error: {0}")]
GenericError(String), GenericError(String),
#[error("No base environment for {0}")]
MissingBaseEnvironment(String),
#[error("Multiple base environments for {0}. Delete duplicates before continuing.")]
MultipleBaseEnvironments(String),
#[error("Row not found")] #[error("Row not found")]
RowNotFound, RowNotFound,
+8 -3
View File
@@ -486,11 +486,12 @@ pub struct Environment {
pub model: String, pub model: String,
pub id: String, pub id: String,
pub workspace_id: String, pub workspace_id: String,
pub environment_id: Option<String>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub name: String, pub name: String,
pub public: bool,
pub base: bool,
pub variables: Vec<EnvironmentVariable>, pub variables: Vec<EnvironmentVariable>,
} }
@@ -523,9 +524,10 @@ impl UpsertModelInfo for Environment {
Ok(vec![ Ok(vec![
(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)),
(EnvironmentId, self.environment_id.into()),
(WorkspaceId, self.workspace_id.into()), (WorkspaceId, self.workspace_id.into()),
(Base, self.base.into()),
(Name, self.name.trim().into()), (Name, self.name.trim().into()),
(Public, self.public.into()),
(Variables, serde_json::to_string(&self.variables)?.into()), (Variables, serde_json::to_string(&self.variables)?.into()),
]) ])
} }
@@ -533,7 +535,9 @@ 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::Name, EnvironmentIden::Name,
EnvironmentIden::Public,
EnvironmentIden::Variables, EnvironmentIden::Variables,
] ]
} }
@@ -547,10 +551,11 @@ impl UpsertModelInfo for Environment {
id: row.get("id")?, id: row.get("id")?,
model: row.get("model")?, model: row.get("model")?,
workspace_id: row.get("workspace_id")?, workspace_id: row.get("workspace_id")?,
environment_id: row.get("environment_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")?,
name: row.get("name")?, name: row.get("name")?,
public: row.get("public")?,
variables: serde_json::from_str(variables.as_str()).unwrap_or_default(), variables: serde_json::from_str(variables.as_str()).unwrap_or_default(),
}) })
} }
@@ -1,8 +1,9 @@
use crate::db_context::DbContext; use crate::db_context::DbContext;
use crate::error::Error::GenericError; use crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments};
use crate::error::Result; use crate::error::Result;
use crate::models::{Environment, EnvironmentIden}; use crate::models::{Environment, EnvironmentIden, EnvironmentVariable};
use crate::util::UpdateSource; use crate::util::UpdateSource;
use log::info;
impl<'a> DbContext<'a> { impl<'a> DbContext<'a> {
pub fn get_environment(&self, id: &str) -> Result<Environment> { pub fn get_environment(&self, id: &str) -> Result<Environment> {
@@ -10,35 +11,41 @@ impl<'a> DbContext<'a> {
} }
pub fn get_base_environment(&self, workspace_id: &str) -> Result<Environment> { pub fn get_base_environment(&self, workspace_id: &str) -> Result<Environment> {
// Will create base environment if it doesn't exist let environments = self.list_environments_ensure_base(workspace_id)?;
let environments = self.list_environments(workspace_id)?; let base_environments =
environments.into_iter().filter(|e| e.base).collect::<Vec<Environment>>();
let base_environment = environments if base_environments.len() > 1 {
.into_iter() return Err(MultipleBaseEnvironments(workspace_id.to_string()));
.find(|e| e.environment_id == None && e.workspace_id == workspace_id) }
.ok_or(GenericError(format!("No base environment found for {workspace_id}")))?;
let base_environment = base_environments.into_iter().find(|e| e.base).ok_or(
// Should never happen because one should be created above if it does not exist
MissingBaseEnvironment(workspace_id.to_string()),
)?;
Ok(base_environment) Ok(base_environment)
} }
pub fn list_environments(&self, workspace_id: &str) -> Result<Vec<Environment>> { /// Lists environments and will create a base environment if one doesn't exist
pub fn list_environments_ensure_base(&self, workspace_id: &str) -> Result<Vec<Environment>> {
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 let base_environment = environments.iter().find(|e| e.base);
.iter()
.find(|e| e.environment_id == None && e.workspace_id == workspace_id);
if let None = base_environment { if let None = base_environment {
environments.push(self.upsert_environment( let e = self.upsert_environment(
&Environment { &Environment {
workspace_id: workspace_id.to_string(), workspace_id: workspace_id.to_string(),
environment_id: None, base: true,
name: "Global Variables".to_string(), name: "Global Variables".to_string(),
..Default::default() ..Default::default()
}, },
&UpdateSource::Background, &UpdateSource::Background,
)?); )?;
info!("Created base environment {} for {workspace_id}", e.id);
environments.push(e);
} }
Ok(environments) Ok(environments)
@@ -49,12 +56,12 @@ impl<'a> DbContext<'a> {
environment: &Environment, environment: &Environment,
source: &UpdateSource, source: &UpdateSource,
) -> Result<Environment> { ) -> Result<Environment> {
for environment in let deleted_environment = self.delete(environment, source)?;
self.find_many::<Environment>(EnvironmentIden::EnvironmentId, &environment.id, None)?
{ // Recreate the base environment if we happened to delete it
self.delete_environment(&environment, source)?; self.list_environments_ensure_base(&environment.workspace_id)?;
}
self.delete(environment, source) Ok(deleted_environment)
} }
pub fn delete_environment_by_id(&self, id: &str, source: &UpdateSource) -> Result<Environment> { pub fn delete_environment_by_id(&self, id: &str, source: &UpdateSource) -> Result<Environment> {
@@ -69,7 +76,7 @@ impl<'a> DbContext<'a> {
) -> Result<Environment> { ) -> Result<Environment> {
let mut environment = environment.clone(); let mut environment = environment.clone();
environment.id = "".to_string(); environment.id = "".to_string();
self.upsert(&environment, source) self.upsert_environment(&environment, source)
} }
pub fn upsert_environment( pub fn upsert_environment(
@@ -77,6 +84,18 @@ impl<'a> DbContext<'a> {
environment: &Environment, environment: &Environment,
source: &UpdateSource, source: &UpdateSource,
) -> Result<Environment> { ) -> Result<Environment> {
self.upsert(environment, source) let cleaned_variables = environment
.variables
.iter()
.filter(|v| !v.name.is_empty() || !v.value.is_empty())
.cloned()
.collect::<Vec<EnvironmentVariable>>();
self.upsert(
&Environment {
variables: cleaned_variables,
..environment.clone()
},
source,
)
} }
} }
@@ -2,6 +2,7 @@ use crate::db_context::DbContext;
use crate::error::Result; use crate::error::Result;
use crate::models::{WorkspaceMeta, WorkspaceMetaIden}; use crate::models::{WorkspaceMeta, WorkspaceMetaIden};
use crate::util::UpdateSource; use crate::util::UpdateSource;
use log::info;
impl<'a> DbContext<'a> { impl<'a> DbContext<'a> {
pub fn get_workspace_meta(&self, workspace_id: &str) -> Option<WorkspaceMeta> { pub fn get_workspace_meta(&self, workspace_id: &str) -> Option<WorkspaceMeta> {
@@ -23,10 +24,7 @@ impl<'a> DbContext<'a> {
Ok(workspace_metas) Ok(workspace_metas)
} }
pub fn get_or_create_workspace_meta( pub fn get_or_create_workspace_meta(&self, workspace_id: &str) -> Result<WorkspaceMeta> {
&self,
workspace_id: &str,
) -> Result<WorkspaceMeta> {
let workspace_meta = self.get_workspace_meta(workspace_id); let workspace_meta = self.get_workspace_meta(workspace_id);
if let Some(workspace_meta) = workspace_meta { if let Some(workspace_meta) = workspace_meta {
return Ok(workspace_meta); return Ok(workspace_meta);
@@ -37,6 +35,8 @@ impl<'a> DbContext<'a> {
..Default::default() ..Default::default()
}; };
info!("Creating WorkspaceMeta for {workspace_id}");
self.upsert_workspace_meta(&workspace_meta, &UpdateSource::Background) self.upsert_workspace_meta(&workspace_meta, &UpdateSource::Background)
} }
@@ -1,8 +1,8 @@
use crate::db_context::DbContext; use crate::db_context::DbContext;
use crate::error::Result; use crate::error::Result;
use crate::models::{ use crate::models::{
Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestIden, EnvironmentIden, FolderIden, GrpcRequestIden, HttpRequestIden, WebsocketRequestIden, Workspace,
WebsocketRequest, WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceIden,
}; };
use crate::util::UpdateSource; use crate::util::UpdateSource;
@@ -34,22 +34,24 @@ impl<'a> DbContext<'a> {
workspace: &Workspace, workspace: &Workspace,
source: &UpdateSource, source: &UpdateSource,
) -> Result<Workspace> { ) -> Result<Workspace> {
for m in self.find_many::<HttpRequest>(HttpRequestIden::WorkspaceId, &workspace.id, None)? { for m in self.find_many(HttpRequestIden::WorkspaceId, &workspace.id, None)? {
self.delete_http_request(&m, source)?; self.delete_http_request(&m, source)?;
} }
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::WorkspaceId, &workspace.id, None)? { for m in self.find_many(GrpcRequestIden::WorkspaceId, &workspace.id, None)? {
self.delete_grpc_request(&m, source)?; self.delete_grpc_request(&m, source)?;
} }
for m in for m in self.find_many(WebsocketRequestIden::FolderId, &workspace.id, None)? {
self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, &workspace.id, None)?
{
self.delete_websocket_request(&m, source)?; self.delete_websocket_request(&m, source)?;
} }
for folder in self.find_many::<Folder>(FolderIden::WorkspaceId, &workspace.id, None)? { for m in self.find_many(FolderIden::WorkspaceId, &workspace.id, None)? {
self.delete_folder(&folder, source)?; self.delete_folder(&m, source)?;
}
for m in self.find_many(EnvironmentIden::WorkspaceId, &workspace.id, None)? {
self.delete_environment(&m, source)?;
} }
self.delete(workspace, source) self.delete(workspace, source)
+14 -10
View File
@@ -1,5 +1,8 @@
use crate::error::Result; use crate::error::Result;
use crate::models::{AnyModel, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo, WebsocketRequest, Workspace, WorkspaceIden}; use crate::models::{
AnyModel, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo, WebsocketRequest,
Workspace, WorkspaceIden,
};
use crate::query_manager::QueryManagerExt; use crate::query_manager::QueryManagerExt;
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use log::warn; use log::warn;
@@ -117,14 +120,14 @@ pub struct BatchUpsertResult {
pub websocket_requests: Vec<WebsocketRequest>, pub websocket_requests: Vec<WebsocketRequest>,
} }
pub async fn get_workspace_export_resources<R: Runtime>( pub fn get_workspace_export_resources<R: Runtime>(
app_handle: &AppHandle<R>, app_handle: &AppHandle<R>,
workspace_ids: Vec<&str>, workspace_ids: Vec<&str>,
include_environments: bool, include_private_environments: bool,
) -> Result<WorkspaceExport> { ) -> Result<WorkspaceExport> {
let mut data = WorkspaceExport { let mut data = WorkspaceExport {
yaak_version: app_handle.package_info().version.clone().to_string(), yaak_version: app_handle.package_info().version.clone().to_string(),
yaak_schema: 3, yaak_schema: 4,
timestamp: Utc::now().naive_utc(), timestamp: Utc::now().naive_utc(),
resources: BatchUpsertResult { resources: BatchUpsertResult {
workspaces: Vec::new(), workspaces: Vec::new(),
@@ -139,18 +142,19 @@ pub async fn get_workspace_export_resources<R: Runtime>(
let db = app_handle.db(); let db = app_handle.db();
for workspace_id in workspace_ids { for workspace_id in workspace_ids {
data.resources.workspaces.push(db.find_one(WorkspaceIden::Id, workspace_id)?); data.resources.workspaces.push(db.find_one(WorkspaceIden::Id, workspace_id)?);
data.resources.environments.append(&mut db.list_environments(workspace_id)?); data.resources.environments.append(
&mut db
.list_environments_ensure_base(workspace_id)?
.into_iter()
.filter(|e| include_private_environments || e.public)
.collect(),
);
data.resources.folders.append(&mut db.list_folders(workspace_id)?); data.resources.folders.append(&mut db.list_folders(workspace_id)?);
data.resources.http_requests.append(&mut db.list_http_requests(workspace_id)?); data.resources.http_requests.append(&mut db.list_http_requests(workspace_id)?);
data.resources.grpc_requests.append(&mut db.list_grpc_requests(workspace_id)?); data.resources.grpc_requests.append(&mut db.list_grpc_requests(workspace_id)?);
data.resources.websocket_requests.append(&mut db.list_websocket_requests(workspace_id)?); data.resources.websocket_requests.append(&mut db.list_websocket_requests(workspace_id)?);
} }
// Nuke environments if we don't want them
if !include_environments {
data.resources.environments.clear();
}
Ok(data) Ok(data)
} }
@@ -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, environmentId: string | null, createdAt: string, updatedAt: string, name: string, variables: Array<EnvironmentVariable>, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
+1 -1
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, environmentId: string | null, createdAt: string, updatedAt: string, name: string, variables: Array<EnvironmentVariable>, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
+1 -1
View File
@@ -4,4 +4,4 @@ import type { SyncState } from "./gen_models.js";
export type FsCandidate = { "type": "FsCandidate", model: SyncModel, relPath: string, checksum: string, }; export type FsCandidate = { "type": "FsCandidate", model: SyncModel, relPath: string, checksum: string, };
export type SyncOp = { "type": "fsCreate", model: SyncModel, } | { "type": "fsUpdate", model: SyncModel, state: SyncState, } | { "type": "fsDelete", state: SyncState, fs: FsCandidate | null, } | { "type": "dbCreate", fs: FsCandidate, } | { "type": "dbUpdate", state: SyncState, fs: FsCandidate, } | { "type": "dbDelete", model: SyncModel, state: SyncState, }; export type SyncOp = { "type": "fsCreate", model: SyncModel, } | { "type": "fsUpdate", model: SyncModel, state: SyncState, } | { "type": "fsDelete", state: SyncState, fs: FsCandidate | null, } | { "type": "dbCreate", fs: FsCandidate, } | { "type": "dbUpdate", state: SyncState, fs: FsCandidate, } | { "type": "dbDelete", model: SyncModel, state: SyncState, } | { "type": "ignorePrivate", model: SyncModel, };
+7 -8
View File
@@ -1,7 +1,7 @@
use crate::error::Result; use crate::error::Result;
use crate::sync::{ use crate::sync::{
apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates, get_fs_candidates, apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates, get_fs_candidates, FsCandidate,
FsCandidate, SyncOp, SyncOp,
}; };
use crate::watch::{watch_directory, WatchEvent}; use crate::watch::{watch_directory, WatchEvent};
use chrono::Utc; use chrono::Utc;
@@ -19,9 +19,8 @@ pub async fn calculate<R: Runtime>(
workspace_id: &str, workspace_id: &str,
sync_dir: &Path, sync_dir: &Path,
) -> Result<Vec<SyncOp>> { ) -> Result<Vec<SyncOp>> {
let db_candidates = get_db_candidates(&app_handle, workspace_id, sync_dir).await?; let db_candidates = get_db_candidates(&app_handle, workspace_id, sync_dir)?;
let fs_candidates = get_fs_candidates(sync_dir) let fs_candidates = get_fs_candidates(sync_dir)?
.await?
.into_iter() .into_iter()
// Only keep items in the same workspace // Only keep items in the same workspace
.filter(|fs| fs.model.workspace_id() == workspace_id) .filter(|fs| fs.model.workspace_id() == workspace_id)
@@ -34,7 +33,7 @@ pub async fn calculate<R: Runtime>(
#[command] #[command]
pub async fn calculate_fs(dir: &Path) -> Result<Vec<SyncOp>> { pub async fn calculate_fs(dir: &Path) -> Result<Vec<SyncOp>> {
let db_candidates = Vec::new(); let db_candidates = Vec::new();
let fs_candidates = get_fs_candidates(dir).await?; let fs_candidates = get_fs_candidates(dir)?;
Ok(compute_sync_ops(db_candidates, fs_candidates)) Ok(compute_sync_ops(db_candidates, fs_candidates))
} }
@@ -45,8 +44,8 @@ pub async fn apply<R: Runtime>(
sync_dir: &Path, sync_dir: &Path,
workspace_id: &str, workspace_id: &str,
) -> Result<()> { ) -> Result<()> {
let sync_state_ops = apply_sync_ops(&app_handle, &workspace_id, sync_dir, sync_ops).await?; let sync_state_ops = apply_sync_ops(&app_handle, &workspace_id, sync_dir, sync_ops)?;
apply_sync_state_ops(&app_handle, workspace_id, sync_dir, sync_state_ops).await apply_sync_state_ops(&app_handle, workspace_id, sync_dir, sync_state_ops)
} }
#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Serialize, Deserialize, TS)]
+112 -75
View File
@@ -1,15 +1,15 @@
use crate::error::Result; use crate::error::Result;
use crate::models::SyncModel; use crate::models::SyncModel;
use chrono::Utc; use chrono::Utc;
use log::{debug, info, warn}; use log::{info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::fs;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Runtime};
use tokio::fs;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use ts_rs::TS; use ts_rs::TS;
use yaak_models::models::{SyncState, WorkspaceMeta}; use yaak_models::models::{SyncState, WorkspaceMeta};
use yaak_models::query_manager::QueryManagerExt; use yaak_models::query_manager::QueryManagerExt;
@@ -41,17 +41,21 @@ pub(crate) enum SyncOp {
model: SyncModel, model: SyncModel,
state: SyncState, state: SyncState,
}, },
IgnorePrivate {
model: SyncModel,
},
} }
impl SyncOp { impl SyncOp {
fn workspace_id(&self) -> String { fn workspace_id(&self) -> String {
match self { match self {
SyncOp::FsCreate { model } => model.workspace_id(),
SyncOp::FsUpdate { state, .. } => state.workspace_id.clone(),
SyncOp::FsDelete { state, .. } => state.workspace_id.clone(),
SyncOp::DbCreate { fs } => fs.model.workspace_id(), SyncOp::DbCreate { fs } => fs.model.workspace_id(),
SyncOp::DbUpdate { state, .. } => state.workspace_id.clone(),
SyncOp::DbDelete { model, .. } => model.workspace_id(), SyncOp::DbDelete { model, .. } => model.workspace_id(),
SyncOp::DbUpdate { state, .. } => state.workspace_id.clone(),
SyncOp::FsCreate { model } => model.workspace_id(),
SyncOp::FsDelete { state, .. } => state.workspace_id.clone(),
SyncOp::FsUpdate { state, .. } => state.workspace_id.clone(),
SyncOp::IgnorePrivate { model } => model.workspace_id(),
} }
} }
} }
@@ -66,6 +70,7 @@ impl Display for SyncOp {
SyncOp::DbCreate { fs } => format!("db_create({})", fs.model.id()), SyncOp::DbCreate { fs } => format!("db_create({})", fs.model.id()),
SyncOp::DbUpdate { fs, .. } => format!("db_update({})", fs.model.id()), SyncOp::DbUpdate { fs, .. } => format!("db_update({})", fs.model.id()),
SyncOp::DbDelete { model, .. } => format!("db_delete({})", model.id()), SyncOp::DbDelete { model, .. } => format!("db_delete({})", model.id()),
SyncOp::IgnorePrivate { model } => format!("ignore_private({})", model.id()),
} }
.as_str(), .as_str(),
) )
@@ -76,8 +81,8 @@ impl Display for SyncOp {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub(crate) enum DbCandidate { pub(crate) enum DbCandidate {
Added(SyncModel), Added(SyncModel),
Modified(SyncModel, SyncState),
Deleted(SyncState), Deleted(SyncState),
Modified(SyncModel, SyncState),
Unmodified(SyncModel, SyncState), Unmodified(SyncModel, SyncState),
} }
@@ -85,8 +90,8 @@ impl DbCandidate {
fn model_id(&self) -> String { fn model_id(&self) -> String {
match &self { match &self {
DbCandidate::Added(m) => m.id(), DbCandidate::Added(m) => m.id(),
DbCandidate::Modified(m, _) => m.id(),
DbCandidate::Deleted(s) => s.model_id.clone(), DbCandidate::Deleted(s) => s.model_id.clone(),
DbCandidate::Modified(m, _) => m.id(),
DbCandidate::Unmodified(m, _) => m.id(), DbCandidate::Unmodified(m, _) => m.id(),
} }
} }
@@ -101,16 +106,13 @@ pub(crate) struct FsCandidate {
pub(crate) checksum: String, pub(crate) checksum: String,
} }
pub(crate) async fn get_db_candidates<R: Runtime>( pub(crate) fn get_db_candidates<R: Runtime>(
app_handle: &AppHandle<R>, app_handle: &AppHandle<R>,
workspace_id: &str, workspace_id: &str,
sync_dir: &Path, sync_dir: &Path,
) -> Result<Vec<DbCandidate>> { ) -> Result<Vec<DbCandidate>> {
let models: HashMap<_, _> = workspace_models(app_handle, workspace_id) let models: HashMap<_, _> =
.await? workspace_models(app_handle, workspace_id)?.into_iter().map(|m| (m.id(), m)).collect();
.into_iter()
.map(|m| (m.id(), m))
.collect();
let sync_states: HashMap<_, _> = app_handle let sync_states: HashMap<_, _> = app_handle
.db() .db()
.list_sync_states_for_workspace(workspace_id, sync_dir)? .list_sync_states_for_workspace(workspace_id, sync_dir)?
@@ -121,20 +123,42 @@ pub(crate) async fn get_db_candidates<R: Runtime>(
// 1. Add candidates for models (created/modified/unmodified) // 1. Add candidates for models (created/modified/unmodified)
let mut candidates: Vec<DbCandidate> = models let mut candidates: Vec<DbCandidate> = models
.values() .values()
.map(|model| { .filter_map(|model| {
let existing_sync_state = match sync_states.get(&model.id()) { match sync_states.get(&model.id()) {
Some(s) => s, Some(existing_sync_state) => {
None => { // If a sync state exists but the model is now private, treat it as a deletion
// No sync state yet, so model was just added match model {
return DbCandidate::Added(model.to_owned()); SyncModel::Environment(e) if !e.public => {
return Some(DbCandidate::Deleted(existing_sync_state.to_owned()));
} }
_ => {}
}; };
let updated_since_flush = model.updated_at() > existing_sync_state.flushed_at; let updated_since_flush = model.updated_at() > existing_sync_state.flushed_at;
if updated_since_flush { if updated_since_flush {
DbCandidate::Modified(model.to_owned(), existing_sync_state.to_owned()) Some(DbCandidate::Modified(
model.to_owned(),
existing_sync_state.to_owned(),
))
} else { } else {
DbCandidate::Unmodified(model.to_owned(), existing_sync_state.to_owned()) Some(DbCandidate::Unmodified(
model.to_owned(),
existing_sync_state.to_owned(),
))
}
}
None => {
return match model {
SyncModel::Environment(e) if !e.public => {
// No sync state yet, so ignore the model
None
}
_ => {
// No sync state yet, so the model was just added
Some(DbCandidate::Added(model.to_owned()))
}
};
}
} }
}) })
.collect(); .collect();
@@ -151,30 +175,28 @@ pub(crate) async fn get_db_candidates<R: Runtime>(
Ok(candidates) Ok(candidates)
} }
pub(crate) async fn get_fs_candidates(dir: &Path) -> Result<Vec<FsCandidate>> { pub(crate) fn get_fs_candidates(dir: &Path) -> Result<Vec<FsCandidate>> {
// Ensure the root directory exists // Ensure the root directory exists
fs::create_dir_all(dir).await?; fs::create_dir_all(dir)?;
let mut candidates = Vec::new(); let mut candidates = Vec::new();
let mut entries = fs::read_dir(dir).await?; let entries = fs::read_dir(dir)?;
while let Some(dir_entry) = entries.next_entry().await? { for dir_entry in entries {
if !dir_entry.file_type().await?.is_file() { let dir_entry = match dir_entry {
Ok(v) => v,
Err(_) => continue,
};
if !dir_entry.file_type()?.is_file() {
continue; continue;
}; };
let path = dir_entry.path(); let path = dir_entry.path();
let (model, checksum) = match SyncModel::from_file(&path) { let (model, checksum) = match SyncModel::from_file(&path) {
// TODO: Remove this once we have logic to handle environments. This it to clean
// any existing ones from the sync dir that resulted from the 2025.1 betas.
Ok(Some((SyncModel::Environment(e), _))) => {
fs::remove_file(path).await?;
info!("Cleaned up synced environment {}", e.id);
continue;
}
Ok(Some(m)) => m, Ok(Some(m)) => m,
Ok(None) => continue, Ok(None) => continue,
Err(e) => { Err(e) => {
warn!("Failed to read sync file {e}"); warn!("Failed to parse sync file {e}");
return Err(e); return Err(e);
} }
}; };
@@ -212,32 +234,32 @@ pub(crate) fn compute_sync_ops(
let op = match (db_map.get(k), fs_map.get(k)) { let op = match (db_map.get(k), fs_map.get(k)) {
(None, None) => return None, // Can never happen (None, None) => return None, // Can never happen
(None, Some(fs)) => SyncOp::DbCreate { fs: fs.to_owned() }, (None, Some(fs)) => SyncOp::DbCreate { fs: fs.to_owned() },
(Some(DbCandidate::Unmodified(model, sync_state)), None) => {
// TODO: Remove this once we have logic to handle environments. This it to // DB unchanged <-> FS missing
// ignore the cleaning we did above of any environments that were written (Some(DbCandidate::Unmodified(model, sync_state)), None) => SyncOp::DbDelete {
// to disk in the 2025.1 betas.
if let SyncModel::Environment(_) = model {
return None;
}
SyncOp::DbDelete {
model: model.to_owned(), model: model.to_owned(),
state: sync_state.to_owned(), state: sync_state.to_owned(),
} },
}
// DB modified <-> FS missing
(Some(DbCandidate::Modified(model, sync_state)), None) => SyncOp::FsUpdate { (Some(DbCandidate::Modified(model, sync_state)), None) => SyncOp::FsUpdate {
model: model.to_owned(), model: model.to_owned(),
state: sync_state.to_owned(), state: sync_state.to_owned(),
}, },
// DB added <-> FS missing
(Some(DbCandidate::Added(model)), None) => SyncOp::FsCreate { (Some(DbCandidate::Added(model)), None) => SyncOp::FsCreate {
model: model.to_owned(), model: model.to_owned(),
}, },
(Some(DbCandidate::Deleted(sync_state)), None) => {
// DB deleted <-> FS missing
// Already deleted on FS, but sending it so the SyncState gets dealt with // Already deleted on FS, but sending it so the SyncState gets dealt with
SyncOp::FsDelete { (Some(DbCandidate::Deleted(sync_state)), None) => SyncOp::FsDelete {
state: sync_state.to_owned(), state: sync_state.to_owned(),
fs: None, fs: None,
} },
}
// DB unchanged <-> FS exists
(Some(DbCandidate::Unmodified(_, sync_state)), Some(fs_candidate)) => { (Some(DbCandidate::Unmodified(_, sync_state)), Some(fs_candidate)) => {
if sync_state.checksum == fs_candidate.checksum { if sync_state.checksum == fs_candidate.checksum {
return None; return None;
@@ -248,6 +270,8 @@ pub(crate) fn compute_sync_ops(
} }
} }
} }
// DB modified <-> FS exists
(Some(DbCandidate::Modified(model, sync_state)), Some(fs_candidate)) => { (Some(DbCandidate::Modified(model, sync_state)), Some(fs_candidate)) => {
if sync_state.checksum == fs_candidate.checksum { if sync_state.checksum == fs_candidate.checksum {
SyncOp::FsUpdate { SyncOp::FsUpdate {
@@ -255,25 +279,29 @@ pub(crate) fn compute_sync_ops(
state: sync_state.to_owned(), state: sync_state.to_owned(),
} }
} else if model.updated_at() < fs_candidate.model.updated_at() { } else if model.updated_at() < fs_candidate.model.updated_at() {
// CONFLICT! Write to DB if fs model is newer // CONFLICT! Write to DB if the fs model is newer
SyncOp::DbUpdate { SyncOp::DbUpdate {
state: sync_state.to_owned(), state: sync_state.to_owned(),
fs: fs_candidate.to_owned(), fs: fs_candidate.to_owned(),
} }
} else { } else {
// CONFLICT! Write to FS if db model is newer // CONFLICT! Write to FS if the db model is newer
SyncOp::FsUpdate { SyncOp::FsUpdate {
model: model.to_owned(), model: model.to_owned(),
state: sync_state.to_owned(), state: sync_state.to_owned(),
} }
} }
} }
// DB added <-> FS anything
(Some(DbCandidate::Added(model)), Some(_)) => { (Some(DbCandidate::Added(model)), Some(_)) => {
// This would be super rare (impossible?), so let's follow the user's intention // This would be super rare (impossible?), so let's follow the user's intention
SyncOp::FsCreate { SyncOp::FsCreate {
model: model.to_owned(), model: model.to_owned(),
} }
} }
// DB deleted <-> FS exists
(Some(DbCandidate::Deleted(sync_state)), Some(fs_candidate)) => SyncOp::FsDelete { (Some(DbCandidate::Deleted(sync_state)), Some(fs_candidate)) => SyncOp::FsDelete {
state: sync_state.to_owned(), state: sync_state.to_owned(),
fs: Some(fs_candidate.to_owned()), fs: Some(fs_candidate.to_owned()),
@@ -284,12 +312,19 @@ pub(crate) fn compute_sync_ops(
.collect() .collect()
} }
async fn workspace_models<R: Runtime>( fn workspace_models<R: Runtime>(
app_handle: &AppHandle<R>, app_handle: &AppHandle<R>,
workspace_id: &str, workspace_id: &str,
) -> Result<Vec<SyncModel>> { ) -> Result<Vec<SyncModel>> {
let resources = // We want to include private environments here so that we can take them into account during
get_workspace_export_resources(app_handle, vec![workspace_id], true).await?.resources; // the sync process. Otherwise, they would be treated as deleted.
let include_private_environments = true;
let resources = get_workspace_export_resources(
app_handle,
vec![workspace_id],
include_private_environments,
)?
.resources;
let workspace = resources.workspaces.iter().find(|w| w.id == workspace_id); let workspace = resources.workspaces.iter().find(|w| w.id == workspace_id);
let workspace = match workspace { let workspace = match workspace {
@@ -318,7 +353,7 @@ async fn workspace_models<R: Runtime>(
Ok(sync_models) Ok(sync_models)
} }
pub(crate) async fn apply_sync_ops<R: Runtime>( pub(crate) fn apply_sync_ops<R: Runtime>(
app_handle: &AppHandle<R>, app_handle: &AppHandle<R>,
workspace_id: &str, workspace_id: &str,
sync_dir: &Path, sync_dir: &Path,
@@ -328,13 +363,14 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
return Ok(Vec::new()); return Ok(Vec::new());
} }
debug!( info!(
"Applying sync ops {}", "Applying sync ops {}",
sync_ops.iter().map(|op| op.to_string()).collect::<Vec<String>>().join(", ") sync_ops.iter().map(|op| op.to_string()).collect::<Vec<String>>().join(", ")
); );
let mut sync_state_ops = Vec::new(); let mut sync_state_ops = Vec::new();
let mut workspaces_to_upsert = Vec::new(); let mut workspaces_to_upsert = Vec::new();
let environments_to_upsert = Vec::new(); let mut environments_to_upsert = Vec::new();
let mut folders_to_upsert = Vec::new(); let mut folders_to_upsert = Vec::new();
let mut http_requests_to_upsert = Vec::new(); let mut http_requests_to_upsert = Vec::new();
let mut grpc_requests_to_upsert = Vec::new(); let mut grpc_requests_to_upsert = Vec::new();
@@ -351,8 +387,8 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
let rel_path = derive_model_filename(&model); let rel_path = derive_model_filename(&model);
let abs_path = sync_dir.join(rel_path.clone()); let abs_path = sync_dir.join(rel_path.clone());
let (content, checksum) = model.to_file_contents(&rel_path)?; let (content, checksum) = model.to_file_contents(&rel_path)?;
let mut f = File::create(&abs_path).await?; let mut f = File::create(&abs_path)?;
f.write_all(&content).await?; f.write_all(&content)?;
SyncStateOp::Create { SyncStateOp::Create {
model_id: model.id(), model_id: model.id(),
checksum, checksum,
@@ -364,8 +400,8 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
let rel_path = Path::new(&state.rel_path); let rel_path = Path::new(&state.rel_path);
let abs_path = Path::new(&state.sync_dir).join(&rel_path); let abs_path = Path::new(&state.sync_dir).join(&rel_path);
let (content, checksum) = model.to_file_contents(&rel_path)?; let (content, checksum) = model.to_file_contents(&rel_path)?;
let mut f = File::create(&abs_path).await?; let mut f = File::create(&abs_path)?;
f.write_all(&content).await?; f.write_all(&content)?;
SyncStateOp::Update { SyncStateOp::Update {
state: state.to_owned(), state: state.to_owned(),
checksum, checksum,
@@ -383,7 +419,7 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
// Always delete the existing path // Always delete the existing path
let rel_path = Path::new(&state.rel_path); let rel_path = Path::new(&state.rel_path);
let abs_path = Path::new(&state.sync_dir).join(&rel_path); let abs_path = Path::new(&state.sync_dir).join(&rel_path);
fs::remove_file(&abs_path).await?; fs::remove_file(&abs_path)?;
SyncStateOp::Delete { SyncStateOp::Delete {
state: state.to_owned(), state: state.to_owned(),
} }
@@ -395,14 +431,12 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
// Push updates to arrays so we can do them all in a single // Push updates to arrays so we can do them all in a single
// batch upsert to make foreign keys happy // batch upsert to make foreign keys happy
match fs.model { match fs.model {
SyncModel::Workspace(m) => workspaces_to_upsert.push(m), SyncModel::Environment(m) => environments_to_upsert.push(m),
SyncModel::Folder(m) => folders_to_upsert.push(m), SyncModel::Folder(m) => folders_to_upsert.push(m),
SyncModel::HttpRequest(m) => http_requests_to_upsert.push(m),
SyncModel::GrpcRequest(m) => grpc_requests_to_upsert.push(m), SyncModel::GrpcRequest(m) => grpc_requests_to_upsert.push(m),
SyncModel::HttpRequest(m) => http_requests_to_upsert.push(m),
SyncModel::WebsocketRequest(m) => websocket_requests_to_upsert.push(m), SyncModel::WebsocketRequest(m) => websocket_requests_to_upsert.push(m),
SyncModel::Workspace(m) => workspaces_to_upsert.push(m),
// TODO: Handle environments in sync
SyncModel::Environment(_) => {}
}; };
SyncStateOp::Create { SyncStateOp::Create {
model_id, model_id,
@@ -414,14 +448,12 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
// Push updates to arrays so we can do them all in a single // Push updates to arrays so we can do them all in a single
// batch upsert to make foreign keys happy // batch upsert to make foreign keys happy
match fs.model { match fs.model {
SyncModel::Workspace(m) => workspaces_to_upsert.push(m), SyncModel::Environment(m) => environments_to_upsert.push(m),
SyncModel::Folder(m) => folders_to_upsert.push(m), SyncModel::Folder(m) => folders_to_upsert.push(m),
SyncModel::HttpRequest(m) => http_requests_to_upsert.push(m),
SyncModel::GrpcRequest(m) => grpc_requests_to_upsert.push(m), SyncModel::GrpcRequest(m) => grpc_requests_to_upsert.push(m),
SyncModel::HttpRequest(m) => http_requests_to_upsert.push(m),
SyncModel::WebsocketRequest(m) => websocket_requests_to_upsert.push(m), SyncModel::WebsocketRequest(m) => websocket_requests_to_upsert.push(m),
SyncModel::Workspace(m) => workspaces_to_upsert.push(m),
// TODO: Handle environments in sync
SyncModel::Environment(_) => {}
} }
SyncStateOp::Update { SyncStateOp::Update {
state: state.to_owned(), state: state.to_owned(),
@@ -435,6 +467,7 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
state: state.to_owned(), state: state.to_owned(),
} }
} }
SyncOp::IgnorePrivate { .. } => SyncStateOp::NoOp,
}); });
} }
@@ -497,9 +530,10 @@ pub(crate) enum SyncStateOp {
Delete { Delete {
state: SyncState, state: SyncState,
}, },
NoOp,
} }
pub(crate) async fn apply_sync_state_ops<R: Runtime>( pub(crate) fn apply_sync_state_ops<R: Runtime>(
app_handle: &AppHandle<R>, app_handle: &AppHandle<R>,
workspace_id: &str, workspace_id: &str,
sync_dir: &Path, sync_dir: &Path,
@@ -540,6 +574,9 @@ pub(crate) async fn apply_sync_state_ops<R: Runtime>(
SyncStateOp::Delete { state } => { SyncStateOp::Delete { state } => {
app_handle.db().delete_sync_state(&state)?; app_handle.db().delete_sync_state(&state)?;
} }
SyncStateOp::NoOp => {
// Nothing
}
} }
} }
Ok(()) Ok(())
+2 -3
View File
@@ -1,5 +1,5 @@
import { useCopy } from '../hooks/useCopy';
import { useTimedBoolean } from '../hooks/useTimedBoolean'; import { useTimedBoolean } from '../hooks/useTimedBoolean';
import { copyToClipboard } from '../lib/copy';
import { showToast } from '../lib/toast'; import { showToast } from '../lib/toast';
import type { ButtonProps } from './core/Button'; import type { ButtonProps } from './core/Button';
import { Button } from './core/Button'; import { Button } from './core/Button';
@@ -9,7 +9,6 @@ interface Props extends Omit<ButtonProps, 'onClick'> {
} }
export function CopyButton({ text, ...props }: Props) { export function CopyButton({ text, ...props }: Props) {
const copy = useCopy({ disableToast: true });
const [copied, setCopied] = useTimedBoolean(); const [copied, setCopied] = useTimedBoolean();
return ( return (
<Button <Button
@@ -23,7 +22,7 @@ export function CopyButton({ text, ...props }: Props) {
message: 'Failed to copy', message: 'Failed to copy',
}); });
} else { } else {
copy(content); copyToClipboard(content, { disableToast: true });
setCopied(); setCopied();
} }
}} }}
+2 -3
View File
@@ -1,5 +1,5 @@
import { useCopy } from '../hooks/useCopy';
import { useTimedBoolean } from '../hooks/useTimedBoolean'; import { useTimedBoolean } from '../hooks/useTimedBoolean';
import { copyToClipboard } from '../lib/copy';
import { showToast } from '../lib/toast'; import { showToast } from '../lib/toast';
import type { IconButtonProps } from './core/IconButton'; import type { IconButtonProps } from './core/IconButton';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
@@ -9,7 +9,6 @@ interface Props extends Omit<IconButtonProps, 'onClick' | 'icon'> {
} }
export function CopyIconButton({ text, ...props }: Props) { export function CopyIconButton({ text, ...props }: Props) {
const copy = useCopy({ disableToast: true });
const [copied, setCopied] = useTimedBoolean(); const [copied, setCopied] = useTimedBoolean();
return ( return (
<IconButton <IconButton
@@ -25,7 +24,7 @@ export function CopyIconButton({ text, ...props }: Props) {
message: 'Failed to copy', message: 'Failed to copy',
}); });
} else { } else {
copy(content); copyToClipboard(content, { disableToast: true });
setCopied(); setCopied();
} }
}} }}
+1 -1
View File
@@ -31,7 +31,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
as="form" as="form"
space={3} space={3}
alignItems="start" alignItems="start"
className="pb-3 max-h-[50vh]" className="pb-3"
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
const workspaceId = await createGlobalModel({ model: 'workspace', name }); const workspaceId = await createGlobalModel({ model: 'workspace', name });
+132 -45
View File
@@ -1,5 +1,5 @@
import type { Environment } from '@yaakapp-internal/models'; import type { Environment } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models'; import { duplicateModel, patchModel } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins'; 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';
@@ -12,6 +12,7 @@ import { useRandomKey } from '../hooks/useRandomKey';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm'; import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { analyzeTemplate, convertTemplateToSecure } from '../lib/encryption'; import { analyzeTemplate, convertTemplateToSecure } from '../lib/encryption';
import { showPrompt } from '../lib/prompt'; import { showPrompt } from '../lib/prompt';
import { resolvedModelName } from '../lib/resolvedModelName';
import { import {
setupOrConfigureEncryption, setupOrConfigureEncryption,
withEncryptionEnabled, withEncryptionEnabled,
@@ -19,18 +20,21 @@ import {
import { BadgeButton } from './core/BadgeButton'; 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 { ContextMenu } from './core/Dropdown'; import { ContextMenu } from './core/Dropdown';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion'; import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { Heading } from './core/Heading'; 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 { InlineCode } from './core/InlineCode'; import { InlineCode } from './core/InlineCode';
import type { PairWithId } from './core/PairEditor'; import type { PairWithId } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor'; import { ensurePairId } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor'; 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 { HStack, VStack } from './core/Stacks'; import { VStack } from './core/Stacks';
interface Props { interface Props {
initialEnvironment: Environment | null; initialEnvironment: Environment | null;
@@ -38,7 +42,8 @@ interface Props {
export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) { export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const createEnvironment = useCreateEnvironment(); const createEnvironment = useCreateEnvironment();
const { baseEnvironment, subEnvironments, allEnvironments } = useEnvironmentsBreakdown(); const { baseEnvironment, otherBaseEnvironments, subEnvironments, allEnvironments } =
useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>( const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
initialEnvironment?.id ?? null, initialEnvironment?.id ?? null,
); );
@@ -51,9 +56,36 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const handleCreateEnvironment = async () => { const handleCreateEnvironment = async () => {
if (baseEnvironment == null) return; if (baseEnvironment == null) return;
const id = await createEnvironment.mutateAsync(baseEnvironment); const id = await createEnvironment.mutateAsync(baseEnvironment);
setSelectedEnvironmentId(id); if (id != null) setSelectedEnvironmentId(id);
}; };
const handleDuplicateEnvironment = useCallback(async (environment: Environment) => {
const name = await showPrompt({
id: 'duplicate-environment',
title: 'Duplicate Environment',
label: 'Name',
defaultValue: environment.name,
});
if (name) {
const newId = await duplicateModel({ ...environment, name, public: false });
setSelectedEnvironmentId(newId);
}
}, []);
const handleDeleteEnvironment = useCallback(
async (environment: Environment) => {
await deleteModelWithConfirm(environment);
if (selectedEnvironmentId === environment.id) {
setSelectedEnvironmentId(baseEnvironment?.id ?? null);
}
},
[baseEnvironment?.id, selectedEnvironmentId],
);
if (baseEnvironment == null) {
return null;
}
return ( return (
<SplitLayout <SplitLayout
name="env_editor" name="env_editor"
@@ -63,24 +95,33 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
firstSlot={() => ( firstSlot={() => (
<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) => (
<SidebarButton <SidebarButton
active={selectedEnvironment?.id == baseEnvironment?.id} key={e.id}
onClick={() => setSelectedEnvironmentId(null)} active={selectedEnvironment?.id == e.id}
environment={null} onClick={() => setSelectedEnvironmentId(e.id)}
rightSlot={ environment={e}
duplicateEnvironment={handleDuplicateEnvironment}
// Allow deleting base environment if there are multiples
deleteEnvironment={
otherBaseEnvironments.length > 0 ? handleDeleteEnvironment : null
}
rightSlot={e.public && sharableTooltip}
outerRightSlot={
<IconButton <IconButton
size="sm" size="sm"
iconSize="md" iconSize="md"
title="Add sub environment" title="Add sub environment"
icon="plus_circle" icon="plus_circle"
iconClassName="text-text-subtlest group-hover:text-text-subtle" iconClassName="text-text-subtlest group-hover:text-text-subtle"
className="group" className="group mr-0.5"
onClick={handleCreateEnvironment} onClick={handleCreateEnvironment}
/> />
} }
> >
{baseEnvironment?.name} {resolvedModelName(e)}
</SidebarButton> </SidebarButton>
))}
{subEnvironments.length > 0 && ( {subEnvironments.length > 0 && (
<div className="px-2"> <div className="px-2">
<Separator className="my-3"></Separator> <Separator className="my-3"></Separator>
@@ -92,11 +133,9 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
active={selectedEnvironment?.id === e.id} active={selectedEnvironment?.id === e.id}
environment={e} environment={e}
onClick={() => setSelectedEnvironmentId(e.id)} onClick={() => setSelectedEnvironmentId(e.id)}
onDelete={() => { rightSlot={e.public && sharableTooltip}
if (e.id === selectedEnvironmentId) { duplicateEnvironment={handleDuplicateEnvironment}
setSelectedEnvironmentId(null); deleteEnvironment={handleDeleteEnvironment}
}
}}
> >
{e.name} {e.name}
</SidebarButton> </SidebarButton>
@@ -143,11 +182,10 @@ const EnvironmentEditor = function ({
); );
const [forceUpdateKey, regenerateForceUpdateKey] = useRandomKey(); const [forceUpdateKey, regenerateForceUpdateKey] = useRandomKey();
// Gather a list of env names from other environments, to help the user get them aligned // Gather a list of env names from other environments to help the user get them aligned
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => { const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
const options: GenericCompletionOption[] = []; const options: GenericCompletionOption[] = [];
const isBaseEnv = activeEnvironment.environmentId == null; if (activeEnvironment.base) {
if (isBaseEnv) {
return { options }; return { options };
} }
@@ -166,10 +204,10 @@ const EnvironmentEditor = function ({
}); });
} }
return { options }; return { options };
}, [activeEnvironment.environmentId, activeEnvironment.id, allEnvironments]); }, [activeEnvironment.base, activeEnvironment.id, allEnvironments]);
const validateName = useCallback((name: string) => { const validateName = useCallback((name: string) => {
// Empty just means the variable doesn't have a name yet, and is unusable // Empty just means the variable doesn't have a name yet and is unusable
if (name === '') return true; if (name === '') return true;
return name.match(/^[a-z_][a-z0-9_-]*$/i) != null; return name.match(/^[a-z_][a-z0-9_-]*$/i) != null;
}, []); }, []);
@@ -177,7 +215,7 @@ const EnvironmentEditor = function ({
const valueType = !isEncryptionEnabled && valueVisibility.value ? 'text' : 'password'; const valueType = !isEncryptionEnabled && valueVisibility.value ? 'text' : 'password';
const promptToEncrypt = useMemo(() => { const promptToEncrypt = useMemo(() => {
if (!isEncryptionEnabled) { if (!isEncryptionEnabled) {
return false; return true;
} else { } else {
return !activeEnvironment.variables.every( return !activeEnvironment.variables.every(
(v) => v.value === '' || analyzeTemplate(v.value) !== 'insecure', (v) => v.value === '' || analyzeTemplate(v.value) !== 'insecure',
@@ -199,28 +237,33 @@ const EnvironmentEditor = function ({
return ( return (
<VStack space={4} className={classNames(className, 'pl-4')}> <VStack space={4} className={classNames(className, 'pl-4')}>
<HStack space={2} className="justify-between"> <Heading className="w-full flex items-center gap-0.5">
<Heading className="w-full flex items-center gap-1"> <div className="mr-2">{activeEnvironment?.name}</div>
<div>{activeEnvironment?.name}</div> {isEncryptionEnabled ? (
{promptToEncrypt ? ( promptToEncrypt ? (
<BadgeButton color="notice" onClick={() => encryptEnvironment(activeEnvironment)}> <BadgeButton color="notice" onClick={() => encryptEnvironment(activeEnvironment)}>
Encrypt All Variables Encrypt All Variables
</BadgeButton> </BadgeButton>
) : isEncryptionEnabled ? ( ) : (
<BadgeButton color="secondary" onClick={setupOrConfigureEncryption}> <BadgeButton color="secondary" onClick={setupOrConfigureEncryption}>
Encryption Settings Encryption Settings
</BadgeButton> </BadgeButton>
)
) : ( ) : (
<IconButton <>
size="sm" <BadgeButton color="secondary" onClick={() => valueVisibility.set((v) => !v)}>
icon={valueVisibility.value ? 'eye' : 'eye_closed'} {valueVisibility.value ? 'Conceal Values' : 'Reveal Values'}
title={valueVisibility.value ? 'Hide Values' : 'Reveal Values'} </BadgeButton>
onClick={() => valueVisibility.set((v) => !v)} </>
/>
)} )}
</Heading> </Heading>
</HStack> {activeEnvironment.public && promptToEncrypt && (
<div className="h-full pr-2 pb-2"> <DismissibleBanner id={activeEnvironment.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 <PairOrBulkEditor
allowMultilineValues allowMultilineValues
preferenceName="environment" preferenceName="environment"
@@ -230,6 +273,7 @@ const EnvironmentEditor = function ({
valueType={valueType} valueType={valueType}
valueAutocompleteVariables valueAutocompleteVariables
valueAutocompleteFunctions valueAutocompleteFunctions
forcedEnvironmentId={activeEnvironment.id}
forceUpdateKey={`${activeEnvironment.id}::${forceUpdateKey}`} forceUpdateKey={`${activeEnvironment.id}::${forceUpdateKey}`}
pairs={activeEnvironment.variables} pairs={activeEnvironment.variables}
onChange={handleChange} onChange={handleChange}
@@ -245,17 +289,21 @@ function SidebarButton({
className, className,
active, active,
onClick, onClick,
onDelete, deleteEnvironment,
rightSlot, rightSlot,
outerRightSlot,
duplicateEnvironment,
environment, environment,
}: { }: {
className?: string; className?: string;
children: ReactNode; children: ReactNode;
active: boolean; active: boolean;
onClick: () => void; onClick: () => void;
onDelete?: () => void;
rightSlot?: ReactNode; rightSlot?: ReactNode;
environment: Environment | null; outerRightSlot?: ReactNode;
environment: Environment;
deleteEnvironment: ((environment: Environment) => void) | null;
duplicateEnvironment: ((environment: Environment) => void) | null;
}) { }) {
const [showContextMenu, setShowContextMenu] = useState<{ const [showContextMenu, setShowContextMenu] = useState<{
x: number; x: number;
@@ -287,19 +335,20 @@ function SidebarButton({
justify="start" justify="start"
onClick={onClick} onClick={onClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
rightSlot={rightSlot}
> >
{children} {children}
</Button> </Button>
{rightSlot} {outerRightSlot}
</div> </div>
{environment != null && (
<ContextMenu <ContextMenu
triggerPosition={showContextMenu} triggerPosition={showContextMenu}
onClose={() => setShowContextMenu(null)} onClose={() => setShowContextMenu(null)}
items={[ items={[
{ {
label: 'Rename', label: 'Rename',
leftSlot: <Icon icon="pencil" size="sm" />, leftSlot: <Icon icon="pencil" />,
hidden: environment.base,
onSelect: async () => { onSelect: async () => {
const name = await showPrompt({ const name = await showPrompt({
id: 'rename-environment', id: 'rename-environment',
@@ -318,18 +367,56 @@ function SidebarButton({
await patchModel(environment, { name }); await patchModel(environment, { name });
}, },
}, },
...((duplicateEnvironment
? [
{
label: 'Duplicate',
leftSlot: <Icon icon="copy" />,
onSelect: () => {
duplicateEnvironment?.(environment);
},
},
]
: []) as DropdownItem[]),
{
label: `Make ${environment.public ? 'Private' : 'Sharable'}`,
leftSlot: <Icon icon={environment.public ? 'eye_closed' : 'eye'} />,
rightSlot: (
<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 () => {
await patchModel(environment, { public: !environment.public });
},
},
...((deleteEnvironment
? [
{ {
color: 'danger', color: 'danger',
label: 'Delete', label: 'Delete',
leftSlot: <Icon icon="trash" size="sm" />, leftSlot: <Icon icon="trash" />,
onSelect: async () => { onSelect: () => {
await deleteModelWithConfirm(environment); deleteEnvironment(environment);
onDelete?.();
}, },
}, },
]
: []) as DropdownItem[]),
]} ]}
/> />
)}
</> </>
); );
} }
const sharableTooltip = (
<IconTooltip
icon="eye"
content="This environment will be included in Directory Sync and data exports"
/>
);
+9 -8
View File
@@ -1,5 +1,5 @@
import { save } from '@tauri-apps/plugin-dialog'; import { save } from '@tauri-apps/plugin-dialog';
import type { Workspace} from '@yaakapp-internal/models'; import type { Workspace } from '@yaakapp-internal/models';
import { workspacesAtom } from '@yaakapp-internal/models'; import { workspacesAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
@@ -41,12 +41,12 @@ function ExportDataDialogContent({
allWorkspaces: Workspace[]; allWorkspaces: Workspace[];
activeWorkspace: Workspace; activeWorkspace: Workspace;
}) { }) {
const [includeEnvironments, setIncludeEnvironments] = useState<boolean>(false); const [includePrivateEnvironments, setIncludePrivateEnvironments] = useState<boolean>(false);
const [selectedWorkspaces, setSelectedWorkspaces] = useState<Record<string, boolean>>({ const [selectedWorkspaces, setSelectedWorkspaces] = useState<Record<string, boolean>>({
[activeWorkspace.id]: true, [activeWorkspace.id]: true,
}); });
// Put active workspace first // Put the active workspace first
const workspaces = useMemo( const workspaces = useMemo(
() => [activeWorkspace, ...allWorkspaces.filter((w) => w.id !== activeWorkspace.id)], () => [activeWorkspace, ...allWorkspaces.filter((w) => w.id !== activeWorkspace.id)],
[activeWorkspace, allWorkspaces], [activeWorkspace, allWorkspaces],
@@ -73,11 +73,11 @@ function ExportDataDialogContent({
await invokeCmd('cmd_export_data', { await invokeCmd('cmd_export_data', {
workspaceIds: ids, workspaceIds: ids,
exportPath, exportPath,
includeEnvironments: includeEnvironments, includePrivateEnvironments: includePrivateEnvironments,
}); });
onHide(); onHide();
onSuccess(exportPath); onSuccess(exportPath);
}, [includeEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]); }, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]);
const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]); const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length; const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
@@ -129,9 +129,10 @@ function ExportDataDialogContent({
<summary className="px-3 py-2">Extra Settings</summary> <summary className="px-3 py-2">Extra Settings</summary>
<div className="px-3 pb-2"> <div className="px-3 pb-2">
<Checkbox <Checkbox
checked={includeEnvironments} checked={includePrivateEnvironments}
onChange={setIncludeEnvironments} onChange={setIncludePrivateEnvironments}
title="Include environments" title="Include private environments"
help='Environments marked as "sharable" will be exported by default'
/> />
</div> </div>
</details> </details>
+12 -7
View File
@@ -1,10 +1,9 @@
import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models'; import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useAtomValue , useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useCopy } from '../hooks/useCopy';
import { import {
activeGrpcConnectionAtom, activeGrpcConnectionAtom,
activeGrpcConnections, activeGrpcConnections,
@@ -12,12 +11,14 @@ import {
useGrpcEvents, useGrpcEvents,
} from '../hooks/usePinnedGrpcConnection'; } from '../hooks/usePinnedGrpcConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps'; import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { copyToClipboard } from '../lib/copy';
import { AutoScroller } from './core/AutoScroller'; import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { Editor } from './core/Editor/Editor';
import { HotKeyList } from './core/HotKeyList';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { JsonAttributeTree } from './core/JsonAttributeTree';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { LoadingIcon } from './core/LoadingIcon'; import { LoadingIcon } from './core/LoadingIcon';
import { Separator } from './core/Separator'; import { Separator } from './core/Separator';
@@ -25,7 +26,6 @@ import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks'; import { HStack, VStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText'; import { EmptyStateText } from './EmptyStateText';
import { RecentGrpcConnectionsDropdown } from './RecentGrpcConnectionsDropdown'; import { RecentGrpcConnectionsDropdown } from './RecentGrpcConnectionsDropdown';
import { HotKeyList } from './core/HotKeyList';
interface Props { interface Props {
style?: CSSProperties; style?: CSSProperties;
@@ -48,7 +48,6 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
const activeConnection = useAtomValue(activeGrpcConnectionAtom); const activeConnection = useAtomValue(activeGrpcConnectionAtom);
const events = useGrpcEvents(activeConnection?.id ?? null); const events = useGrpcEvents(activeConnection?.id ?? null);
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom); const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
const copy = useCopy();
const activeEvent = useMemo( const activeEvent = useMemo(
() => events.find((m) => m.id === activeEventId) ?? null, () => events.find((m) => m.id === activeEventId) ?? null,
@@ -136,7 +135,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
title="Copy message" title="Copy message"
icon="copy" icon="copy"
size="xs" size="xs"
onClick={() => copy(activeEvent.content)} onClick={() => copyToClipboard(activeEvent.content)}
/> />
</div> </div>
{!showLarge && activeEvent.content.length > 1000 * 1000 ? ( {!showLarge && activeEvent.content.length > 1000 * 1000 ? (
@@ -161,7 +160,13 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
</div> </div>
</VStack> </VStack>
) : ( ) : (
<JsonAttributeTree attrValue={JSON.parse(activeEvent?.content ?? '{}')} /> <Editor
language="json"
defaultValue={activeEvent.content ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)} )}
</> </>
) : ( ) : (
+2 -2
View File
@@ -74,13 +74,13 @@ export function SettingsDropdown() {
{ {
label: 'Feedback', label: 'Feedback',
leftSlot: <Icon icon="chat" />, leftSlot: <Icon icon="chat" />,
rightSlot: <Icon icon="external_link" />, rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl('https://yaak.app/feedback'), onSelect: () => openUrl('https://yaak.app/feedback'),
}, },
{ {
label: 'Changelog', label: 'Changelog',
leftSlot: <Icon icon="cake" />, leftSlot: <Icon icon="cake" />,
rightSlot: <Icon icon="external_link" />, rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl(`https://yaak.app/changelog/${appInfo.version}`), onSelect: () => openUrl(`https://yaak.app/changelog/${appInfo.version}`),
}, },
]} ]}
+2 -4
View File
@@ -4,7 +4,6 @@ import { format } from 'date-fns';
import { hexy } from 'hexy'; import { hexy } from 'hexy';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useMemo, useRef, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { useCopy } from '../hooks/useCopy';
import { useFormatText } from '../hooks/useFormatText'; import { useFormatText } from '../hooks/useFormatText';
import { import {
activeWebsocketConnectionAtom, activeWebsocketConnectionAtom,
@@ -14,6 +13,7 @@ import {
} from '../hooks/usePinnedWebsocketConnection'; } from '../hooks/usePinnedWebsocketConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps'; import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { languageFromContentType } from '../lib/contentType'; import { languageFromContentType } from '../lib/contentType';
import { copyToClipboard } from '../lib/copy';
import { AutoScroller } from './core/AutoScroller'; import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import { Button } from './core/Button'; import { Button } from './core/Button';
@@ -41,7 +41,6 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
const activeConnection = useAtomValue(activeWebsocketConnectionAtom); const activeConnection = useAtomValue(activeWebsocketConnectionAtom);
const connections = useAtomValue(activeWebsocketConnectionsAtom); const connections = useAtomValue(activeWebsocketConnectionsAtom);
const events = useWebsocketEvents(activeConnection?.id ?? null); const events = useWebsocketEvents(activeConnection?.id ?? null);
const activeEvent = useMemo( const activeEvent = useMemo(
@@ -63,7 +62,6 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
const language = languageFromContentType(null, message); const language = languageFromContentType(null, message);
const formattedMessage = useFormatText({ language, text: message, pretty: true }); const formattedMessage = useFormatText({ language, text: message, pretty: true });
const copy = useCopy();
return ( return (
<SplitLayout <SplitLayout
@@ -151,7 +149,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
title="Copy message" title="Copy message"
icon="copy" icon="copy"
size="xs" size="xs"
onClick={() => copy(formattedMessage.data ?? '')} onClick={() => copyToClipboard(formattedMessage.data ?? '')}
/> />
</HStack> </HStack>
)} )}
@@ -29,15 +29,41 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
const workspace = useAtomValue(activeWorkspaceAtom); const workspace = useAtomValue(activeWorkspaceAtom);
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom); const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
const [key, setKey] = useState<{ key: string | null; error: string | null } | null>(null);
if (workspace == null || workspaceMeta == null) { useEffect(() => {
if (workspaceMeta == null) {
return;
}
if (workspaceMeta?.encryptionKey == null) {
setKey({ key: null, error: null });
return;
}
revealWorkspaceKey(workspaceMeta.workspaceId).then(
(key) => {
setKey({ key, error: null });
},
(err) => {
setKey({ key: null, error: `${err}` });
},
);
}, [setKey, workspaceMeta, workspaceMeta?.encryptionKey]);
if (key == null || workspace == null || workspaceMeta == null) {
return null; return null;
} }
if (workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null) { // Prompt for key if it doesn't exist or could not be decrypted
if (
key.error != null ||
(workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null)
) {
return ( return (
<EnterWorkspaceKey <EnterWorkspaceKey
workspaceMeta={workspaceMeta} workspaceMeta={workspaceMeta}
error={key.error}
onEnabled={() => { onEnabled={() => {
onDone?.(); onDone?.();
onEnabledEncryption?.(); onEnabledEncryption?.();
@@ -46,12 +72,13 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
); );
} }
if (workspaceMeta.encryptionKey) { // Show the key if it exists
if (workspaceMeta.encryptionKey && key.key != null) {
const keyRevealer = ( const keyRevealer = (
<KeyRevealer <KeyRevealer
disableLabel={justEnabledEncryption} disableLabel={justEnabledEncryption}
defaultShow={justEnabledEncryption} defaultShow={justEnabledEncryption}
workspaceId={workspaceMeta.workspaceId} encryptionKey={key.key}
/> />
); );
return ( return (
@@ -63,10 +90,13 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
)} )}
{keyRevealer} {keyRevealer}
{onDone && ( {onDone && (
<Button color="secondary" onClick={() => { <Button
color="secondary"
onClick={() => {
onDone(); onDone();
onEnabledEncryption?.(); onEnabledEncryption?.();
}}> }}
>
Done Done
</Button> </Button>
)} )}
@@ -74,6 +104,7 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
); );
} }
// Show button to enable encryption
return ( return (
<div className="mb-auto flex flex-col-reverse"> <div className="mb-auto flex flex-col-reverse">
<Button <Button
@@ -107,17 +138,23 @@ const setWorkspaceKeyMut = createFastMutation({
function EnterWorkspaceKey({ function EnterWorkspaceKey({
workspaceMeta, workspaceMeta,
onEnabled, onEnabled,
error,
}: { }: {
workspaceMeta: WorkspaceMeta; workspaceMeta: WorkspaceMeta;
onEnabled?: () => void; onEnabled?: () => void;
error?: string | null;
}) { }) {
const [key, setKey] = useState<string>(''); const [key, setKey] = useState<string>('');
return ( return (
<VStack space={4}> <VStack space={4} className="w-full">
{error ? (
<Banner color="danger">{error}</Banner>
) : (
<Banner color="info"> <Banner color="info">
This workspace contains encrypted values but no key is configured. Please enter the This workspace contains encrypted values but no key is configured. Please enter the
workspace key to access the encrypted data. workspace key to access the encrypted data.
</Banner> </Banner>
)}
<HStack <HStack
as="form" as="form"
alignItems="end" alignItems="end"
@@ -149,23 +186,16 @@ function EnterWorkspaceKey({
} }
function KeyRevealer({ function KeyRevealer({
workspaceId,
defaultShow = false, defaultShow = false,
disableLabel = false, disableLabel = false,
encryptionKey,
}: { }: {
workspaceId: string;
defaultShow?: boolean; defaultShow?: boolean;
disableLabel?: boolean; disableLabel?: boolean;
encryptionKey: string;
}) { }) {
const [key, setKey] = useState<string | null>(null);
const [show, setShow] = useStateWithDeps<boolean>(defaultShow, [defaultShow]); const [show, setShow] = useStateWithDeps<boolean>(defaultShow, [defaultShow]);
useEffect(() => {
revealWorkspaceKey(workspaceId).then(setKey);
}, [setKey, workspaceId]);
if (key == null) return null;
return ( return (
<div <div
className={classNames( className={classNames(
@@ -180,10 +210,10 @@ function KeyRevealer({
<IconTooltip iconSize="sm" size="lg" content={helpAfterEncryption} /> <IconTooltip iconSize="sm" size="lg" content={helpAfterEncryption} />
</span> </span>
)} )}
{key && <HighlightedKey keyText={key} show={show} />} {encryptionKey && <HighlightedKey keyText={encryptionKey} show={show} />}
</VStack> </VStack>
<HStack> <HStack>
{key && <CopyIconButton text={key} title="Copy workspace key" />} {encryptionKey && <CopyIconButton text={encryptionKey} title="Copy workspace key" />}
<IconButton <IconButton
title={show ? 'Hide' : 'Reveal' + 'workspace key'} title={show ? 'Hide' : 'Reveal' + 'workspace key'}
icon={show ? 'eye_closed' : 'eye'} icon={show ? 'eye_closed' : 'eye'}
@@ -7,7 +7,7 @@ import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode'; import { InlineCode } from './core/InlineCode';
import { PlainInput } from './core/PlainInput'; import { PlainInput } from './core/PlainInput';
import { Separator } from './core/Separator'; import { Separator } from './core/Separator';
import { VStack } from './core/Stacks'; import { HStack, VStack } from './core/Stacks';
import { MarkdownEditor } from './MarkdownEditor'; import { MarkdownEditor } from './MarkdownEditor';
import { SyncToFilesystemSetting } from './SyncToFilesystemSetting'; import { SyncToFilesystemSetting } from './SyncToFilesystemSetting';
import { WorkspaceEncryptionSetting } from './WorkspaceEncryptionSetting'; import { WorkspaceEncryptionSetting } from './WorkspaceEncryptionSetting';
@@ -67,6 +67,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide }: Props) {
<Separator className="my-4" /> <Separator className="my-4" />
<HStack alignItems="center" justifyContent="between" className="w-full">
<Button <Button
onClick={async () => { onClick={async () => {
const didDelete = await deleteModelWithConfirm(workspace); const didDelete = await deleteModelWithConfirm(workspace);
@@ -81,6 +82,8 @@ export function WorkspaceSettingsDialog({ workspaceId, hide }: Props) {
> >
Delete Workspace Delete Workspace
</Button> </Button>
<InlineCode className="select-text cursor-text">{workspaceId}</InlineCode>
</HStack>
</VStack> </VStack>
); );
} }
@@ -11,6 +11,7 @@ export function BulkPairEditor({
namePlaceholder, namePlaceholder,
valuePlaceholder, valuePlaceholder,
forceUpdateKey, forceUpdateKey,
forcedEnvironmentId,
stateKey, stateKey,
}: Props) { }: Props) {
const pairsText = useMemo(() => { const pairsText = useMemo(() => {
@@ -36,6 +37,7 @@ export function BulkPairEditor({
autocompleteFunctions autocompleteFunctions
autocompleteVariables autocompleteVariables
stateKey={`bulk_pair.${stateKey}`} stateKey={`bulk_pair.${stateKey}`}
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
placeholder={`${namePlaceholder ?? 'name'}: ${valuePlaceholder ?? 'value'}`} placeholder={`${namePlaceholder ?? 'name'}: ${valuePlaceholder ?? 'value'}`}
defaultValue={pairsText} defaultValue={pairsText}
+11 -11
View File
@@ -2,7 +2,6 @@ import classNames from 'classnames';
import * as m from 'motion/react-m'; import * as m from 'motion/react-m';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useKey } from 'react-use';
import { Overlay } from '../Overlay'; import { Overlay } from '../Overlay';
import { Heading } from './Heading'; import { Heading } from './Heading';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
@@ -42,18 +41,9 @@ export function Dialog({
[description], [description],
); );
useKey(
'Escape',
() => {
if (!open) return;
onClose?.();
},
{},
[open],
);
return ( return (
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog"> <Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<div <div
role="dialog" role="dialog"
className={classNames( className={classNames(
@@ -64,6 +54,16 @@ export function Dialog({
)} )}
aria-labelledby={titleId} aria-labelledby={titleId}
aria-describedby={descriptionId} aria-describedby={descriptionId}
tabIndex={-1}
onKeyDown={(e) => {
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
// dialogs and can be intercepted by children if needed.
if (e.key === 'Escape') {
onClose?.();
e.stopPropagation();
e.preventDefault();
}
}}
> >
<m.div <m.div
initial={{ top: 5, scale: 0.97 }} initial={{ top: 5, scale: 0.97 }}
+10 -7
View File
@@ -2,7 +2,7 @@ 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 { IconButton } from './IconButton'; import { Button } from './Button';
export function DismissibleBanner({ export function DismissibleBanner({
children, children,
@@ -19,14 +19,17 @@ export function DismissibleBanner({
if (dismissed) return null; if (dismissed) return null;
return ( return (
<Banner className={classNames(className, 'relative pr-8')} {...props}> <Banner className={classNames(className, 'relative grid grid-cols-[1fr_auto] gap-3')} {...props}>
<IconButton {children}
className="!absolute right-0 top-0" <Button
icon="x" variant="border"
color={props.color}
size="xs"
onClick={() => setDismissed((d) => !d)} onClick={() => setDismissed((d) => !d)}
title="Dismiss message" title="Dismiss message"
/> >
{children} Dismiss
</Button>
</Banner> </Banner>
); );
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import classNames from 'classnames'; import classNames from 'classnames';
import * as m from 'motion/react-m';
import { atom } from 'jotai'; import { atom } from 'jotai';
import * as m from 'motion/react-m';
import type { import type {
CSSProperties, CSSProperties,
FocusEvent as ReactFocusEvent, FocusEvent as ReactFocusEvent,
@@ -34,9 +34,9 @@ import { Overlay } from '../Overlay';
import { Button } from './Button'; import { Button } from './Button';
import { HotKey } from './HotKey'; import { HotKey } from './HotKey';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { LoadingIcon } from './LoadingIcon';
import { Separator } from './Separator'; import { Separator } from './Separator';
import { HStack, VStack } from './Stacks'; import { HStack, VStack } from './Stacks';
import { LoadingIcon } from './LoadingIcon';
export type DropdownItemSeparator = { export type DropdownItemSeparator = {
type: 'separator'; type: 'separator';
+7 -2
View File
@@ -26,7 +26,8 @@ import {
useMemo, useMemo,
useRef, useRef,
} from 'react'; } from 'react';
import { useActiveEnvironmentVariables } from '../../../hooks/useActiveEnvironmentVariables'; import { activeEnvironmentIdAtom } from '../../../hooks/useActiveEnvironment';
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';
@@ -69,6 +70,7 @@ export interface EditorProps {
disableTabIndent?: boolean; disableTabIndent?: boolean;
disabled?: boolean; disabled?: boolean;
extraExtensions?: Extension[]; extraExtensions?: Extension[];
forcedEnvironmentId?: string;
forceUpdateKey?: string | number; forceUpdateKey?: string | number;
format?: (v: string) => Promise<string>; format?: (v: string) => Promise<string>;
heightMode?: 'auto' | 'full'; heightMode?: 'auto' | 'full';
@@ -108,6 +110,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
disableTabIndent, disableTabIndent,
disabled, disabled,
extraExtensions, extraExtensions,
forcedEnvironmentId,
forceUpdateKey, forceUpdateKey,
format, format,
heightMode, heightMode,
@@ -130,7 +133,9 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
) { ) {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const allEnvironmentVariables = useActiveEnvironmentVariables(); const activeEnvironmentId = useAtomValue(activeEnvironmentIdAtom);
const environmentId = forcedEnvironmentId ?? activeEnvironmentId ?? null;
const allEnvironmentVariables = useEnvironmentVariables(environmentId);
const environmentVariables = autocompleteVariables ? allEnvironmentVariables : emptyVariables; const environmentVariables = autocompleteVariables ? allEnvironmentVariables : emptyVariables;
const useTemplating = !!(autocompleteFunctions || autocompleteVariables || autocomplete); const useTemplating = !!(autocompleteFunctions || autocompleteVariables || autocomplete);
+2 -1
View File
@@ -44,6 +44,7 @@ const icons = {
eye_closed: lucide.EyeOffIcon, eye_closed: lucide.EyeOffIcon,
file_code: lucide.FileCodeIcon, file_code: lucide.FileCodeIcon,
filter: lucide.FilterIcon, filter: lucide.FilterIcon,
flame: lucide.FlameIcon,
flask: lucide.FlaskConicalIcon, flask: lucide.FlaskConicalIcon,
folder: lucide.FolderIcon, folder: lucide.FolderIcon,
folder_git: lucide.FolderGitIcon, folder_git: lucide.FolderGitIcon,
@@ -138,7 +139,7 @@ export const Icon = memo(function Icon({
size === 'xs' && 'h-3 w-3', size === 'xs' && 'h-3 w-3',
size === '2xs' && 'h-2.5 w-2.5', size === '2xs' && 'h-2.5 w-2.5',
color === 'default' && 'inherit', color === 'default' && 'inherit',
color === 'danger' && 'text-danger!', color === 'danger' && 'text-danger',
color === 'warning' && 'text-warning', color === 'warning' && 'text-warning',
color === 'notice' && 'text-notice', color === 'notice' && 'text-notice',
color === 'info' && 'text-info', color === 'info' && 'text-info',
+14 -2
View File
@@ -7,13 +7,25 @@ import { Tooltip } from './Tooltip';
type Props = Omit<TooltipProps, 'children'> & { type Props = Omit<TooltipProps, 'children'> & {
icon?: IconProps['icon']; icon?: IconProps['icon'];
iconSize?: IconProps['size']; iconSize?: IconProps['size'];
iconColor?: IconProps['color'];
className?: string; className?: string;
}; };
export function IconTooltip({ content, icon = 'info', iconSize, ...tooltipProps }: Props) { export function IconTooltip({
content,
icon = 'info',
iconColor,
iconSize,
...tooltipProps
}: Props) {
return ( return (
<Tooltip content={content} {...tooltipProps}> <Tooltip content={content} {...tooltipProps}>
<Icon className="opacity-60 hover:opacity-100" icon={icon} size={iconSize} /> <Icon
className="opacity-60 hover:opacity-100"
icon={icon}
size={iconSize}
color={iconColor}
/>
</Tooltip> </Tooltip>
); );
} }
+18 -3
View File
@@ -30,11 +30,13 @@ import { Icon } from './Icon';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
import { Label } from './Label'; import { Label } from './Label';
import { HStack } from './Stacks'; import { HStack } from './Stacks';
import { copyToClipboard } from '../../lib/copy';
export type InputProps = Pick< export type InputProps = Pick<
EditorProps, EditorProps,
| 'language' | 'language'
| 'autocomplete' | 'autocomplete'
| 'forcedEnvironmentId'
| 'forceUpdateKey' | 'forceUpdateKey'
| 'disabled' | 'disabled'
| 'autoFocus' | 'autoFocus'
@@ -387,19 +389,32 @@ function EncryptionInput({
const dropdownItems = useMemo<DropdownItem[]>( const dropdownItems = useMemo<DropdownItem[]>(
() => [ () => [
{ {
label: state.obscured ? 'Reveal value' : 'Conceal value', label: state.obscured ? 'Reveal' : 'Conceal',
disabled: isEncryptionEnabled && state.fieldType === 'text', disabled: isEncryptionEnabled && state.fieldType === 'text',
leftSlot: <Icon icon={state.obscured ? 'eye' : 'eye_closed'} />, leftSlot: <Icon icon={state.obscured ? 'eye' : 'eye_closed'} />,
onSelect: () => setState((s) => ({ ...s, obscured: !s.obscured })), onSelect: () => setState((s) => ({ ...s, obscured: !s.obscured })),
}, },
{
label: 'Copy',
leftSlot: <Icon icon="copy" />,
hidden: !state.value,
onSelect: () => copyToClipboard(state.value ?? ''),
},
{ type: 'separator' }, { type: 'separator' },
{ {
label: state.fieldType === 'text' ? 'Encrypt Value' : 'Decrypt Value', label: state.fieldType === 'text' ? 'Encrypt Field' : 'Decrypt Field',
leftSlot: <Icon icon={state.fieldType === 'text' ? 'lock' : 'lock_open'} />, leftSlot: <Icon icon={state.fieldType === 'text' ? 'lock' : 'lock_open'} />,
onSelect: () => handleFieldTypeChange(state.fieldType === 'text' ? 'encrypted' : 'text'), onSelect: () => handleFieldTypeChange(state.fieldType === 'text' ? 'encrypted' : 'text'),
}, },
], ],
[handleFieldTypeChange, isEncryptionEnabled, setState, state.fieldType, state.obscured], [
handleFieldTypeChange,
isEncryptionEnabled,
setState,
state.fieldType,
state.obscured,
state.value,
],
); );
let tint: InputProps['tint']; let tint: InputProps['tint'];
+7
View File
@@ -41,6 +41,7 @@ export type PairEditorProps = {
allowFileValues?: boolean; allowFileValues?: boolean;
allowMultilineValues?: boolean; allowMultilineValues?: boolean;
className?: string; className?: string;
forcedEnvironmentId?: string;
forceUpdateKey?: string; forceUpdateKey?: string;
nameAutocomplete?: GenericCompletionConfig; nameAutocomplete?: GenericCompletionConfig;
nameAutocompleteFunctions?: boolean; nameAutocompleteFunctions?: boolean;
@@ -81,6 +82,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
allowFileValues, allowFileValues,
allowMultilineValues, allowMultilineValues,
className, className,
forcedEnvironmentId,
forceUpdateKey, forceUpdateKey,
nameAutocomplete, nameAutocomplete,
nameAutocompleteFunctions, nameAutocompleteFunctions,
@@ -235,6 +237,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
allowFileValues={allowFileValues} allowFileValues={allowFileValues}
allowMultilineValues={allowMultilineValues} allowMultilineValues={allowMultilineValues}
className="py-1" className="py-1"
forcedEnvironmentId={forcedEnvironmentId}
forceFocusNamePairId={forceFocusNamePairId} forceFocusNamePairId={forceFocusNamePairId}
forceFocusValuePairId={forceFocusValuePairId} forceFocusValuePairId={forceFocusValuePairId}
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
@@ -292,6 +295,7 @@ type PairEditorRowProps = {
PairEditorProps, PairEditorProps,
| 'allowFileValues' | 'allowFileValues'
| 'allowMultilineValues' | 'allowMultilineValues'
| 'forcedEnvironmentId'
| 'forceUpdateKey' | 'forceUpdateKey'
| 'nameAutocomplete' | 'nameAutocomplete'
| 'nameAutocompleteVariables' | 'nameAutocompleteVariables'
@@ -311,6 +315,7 @@ function PairEditorRow({
allowFileValues, allowFileValues,
allowMultilineValues, allowMultilineValues,
className, className,
forcedEnvironmentId,
forceFocusNamePairId, forceFocusNamePairId,
forceFocusValuePairId, forceFocusValuePairId,
forceUpdateKey, forceUpdateKey,
@@ -502,6 +507,7 @@ function PairEditorRow({
size="sm" size="sm"
required={!isLast && !!pair.enabled && !!pair.value} required={!isLast && !!pair.enabled && !!pair.value}
validate={nameValidate} validate={nameValidate}
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
containerClassName={classNames(isLast && 'border-dashed')} containerClassName={classNames(isLast && 'border-dashed')}
defaultValue={pair.name} defaultValue={pair.name}
@@ -549,6 +555,7 @@ function PairEditorRow({
size="sm" size="sm"
containerClassName={classNames(isLast && 'border-dashed')} containerClassName={classNames(isLast && 'border-dashed')}
validate={valueValidate} validate={valueValidate}
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
defaultValue={pair.value} defaultValue={pair.value}
label="Value" label="Value"
@@ -8,6 +8,7 @@ import { PairEditor } from './PairEditor';
interface Props extends PairEditorProps { interface Props extends PairEditorProps {
preferenceName: string; preferenceName: string;
forcedEnvironmentId?: string;
} }
export const PairOrBulkEditor = forwardRef<PairEditorRef, Props>(function PairOrBulkEditor( export const PairOrBulkEditor = forwardRef<PairEditorRef, Props>(function PairOrBulkEditor(
+7 -5
View File
@@ -7,6 +7,7 @@ import { Portal } from '../Portal';
export interface TooltipProps { export interface TooltipProps {
children: ReactNode; children: ReactNode;
content: ReactNode; content: ReactNode;
tabIndex?: number,
size?: 'md' | 'lg'; size?: 'md' | 'lg';
} }
@@ -18,7 +19,7 @@ const hiddenStyles: CSSProperties = {
opacity: 0, opacity: 0,
}; };
export function Tooltip({ children, content, size = 'md' }: TooltipProps) { export function Tooltip({ children, content, tabIndex, size = 'md' }: TooltipProps) {
const [isOpen, setIsOpen] = useState<CSSProperties>(); const [isOpen, setIsOpen] = useState<CSSProperties>();
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null); const tooltipRef = useRef<HTMLDivElement>(null);
@@ -89,11 +90,12 @@ export function Tooltip({ children, content, size = 'md' }: TooltipProps) {
<Triangle className="text-border mb-2" /> <Triangle className="text-border mb-2" />
</div> </div>
</Portal> </Portal>
<button <span
ref={triggerRef} ref={triggerRef}
type="button" role="button"
aria-describedby={isOpen ? id.current : undefined} aria-describedby={isOpen ? id.current : undefined}
className="flex-grow-0 inline-flex items-center" tabIndex={tabIndex ?? 0}
className="flex-grow-0 flex items-center"
onClick={handleToggleImmediate} onClick={handleToggleImmediate}
onMouseEnter={handleOpen} onMouseEnter={handleOpen}
onMouseLeave={handleClose} onMouseLeave={handleClose}
@@ -102,7 +104,7 @@ export function Tooltip({ children, content, size = 'md' }: TooltipProps) {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
{children} {children}
</button> </span>
</> </>
); );
} }
+2 -18
View File
@@ -1,24 +1,8 @@
import type { EnvironmentVariable } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { activeEnvironmentAtom } from './useActiveEnvironment'; import { activeEnvironmentAtom } from './useActiveEnvironment';
import { useEnvironmentsBreakdown } from './useEnvironmentsBreakdown'; import { useEnvironmentVariables } from './useEnvironmentVariables';
export function useActiveEnvironmentVariables() { export function useActiveEnvironmentVariables() {
const { baseEnvironment } = useEnvironmentsBreakdown();
const activeEnvironment = useAtomValue(activeEnvironmentAtom); const activeEnvironment = useAtomValue(activeEnvironmentAtom);
return useMemo(() => { return useEnvironmentVariables(activeEnvironment?.id ?? null);
const varMap: Record<string, EnvironmentVariable> = {};
const allVariables = [
...(baseEnvironment?.variables ?? []),
...(activeEnvironment?.variables ?? []),
];
for (const v of allVariables) {
if (!v.enabled || !v.name) continue;
varMap[v.name] = v;
}
return Object.values(varMap);
}, [activeEnvironment, baseEnvironment]);
} }
-26
View File
@@ -1,26 +0,0 @@
import { clear, writeText } from '@tauri-apps/plugin-clipboard-manager';
import { useCallback } from 'react';
import { showToast } from '../lib/toast';
export function useCopy({ disableToast }: { disableToast?: boolean } = {}) {
const copy = useCallback(
(text: string | null) => {
if (text == null) {
clear().catch(console.error);
} else {
writeText(text).catch(console.error);
}
if (text != '' && !disableToast) {
showToast({
id: 'copied',
color: 'success',
icon: 'copy',
message: 'Copied to clipboard',
});
}
},
[disableToast],
);
return copy;
}
+3 -4
View File
@@ -1,15 +1,14 @@
import { useFastMutation } from './useFastMutation';
import type { HttpResponse } from '@yaakapp-internal/models'; import type { HttpResponse } from '@yaakapp-internal/models';
import { useCopy } from './useCopy'; import { copyToClipboard } from '../lib/copy';
import { getResponseBodyText } from '../lib/responseBody'; import { getResponseBodyText } from '../lib/responseBody';
import { useFastMutation } from './useFastMutation';
export function useCopyHttpResponse(response: HttpResponse) { export function useCopyHttpResponse(response: HttpResponse) {
const copy = useCopy();
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(response);
copy(body); copyToClipboard(body);
}, },
}); });
} }
+11 -6
View File
@@ -1,20 +1,21 @@
import type { Environment } from '@yaakapp-internal/models'; import type { Environment } from '@yaakapp-internal/models';
import { createWorkspaceModel } from '@yaakapp-internal/models'; import { createWorkspaceModel } from '@yaakapp-internal/models';
import { jotaiStore } from '../lib/jotai'; import { useAtomValue } from 'jotai';
import { showPrompt } from '../lib/prompt'; import { showPrompt } from '../lib/prompt';
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams'; import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
import { activeWorkspaceIdAtom } from './useActiveWorkspace'; import { activeWorkspaceIdAtom } from './useActiveWorkspace';
import { useFastMutation } from './useFastMutation'; import { useFastMutation } from './useFastMutation';
export function useCreateEnvironment() { export function useCreateEnvironment() {
return useFastMutation<string, unknown, Environment | null>({ const workspaceId = useAtomValue(activeWorkspaceIdAtom);
mutationKey: ['create_environment'],
return useFastMutation<string | null, unknown, Environment | null>({
mutationKey: ['create_environment', workspaceId],
mutationFn: async (baseEnvironment) => { mutationFn: async (baseEnvironment) => {
if (baseEnvironment == null) { if (baseEnvironment == null) {
throw new Error('No base environment passed'); throw new Error('No base environment passed');
} }
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) { if (workspaceId == null) {
throw new Error('Cannot create environment when no active workspace'); throw new Error('Cannot create environment when no active workspace');
} }
@@ -28,17 +29,21 @@ export function useCreateEnvironment() {
defaultValue: 'My Environment', defaultValue: 'My Environment',
confirmText: 'Create', confirmText: 'Create',
}); });
if (name == null) throw new Error('No name provided to create environment'); if (name == null) return null;
return createWorkspaceModel({ return createWorkspaceModel({
model: 'environment', model: 'environment',
name, name,
variables: [], variables: [],
workspaceId, workspaceId,
environmentId: baseEnvironment.id, base: false,
}); });
}, },
onSuccess: async (environmentId) => { onSuccess: async (environmentId) => {
if (environmentId == null) {
return; // Was not created
}
setWorkspaceSearchParams({ environment_id: environmentId }); setWorkspaceSearchParams({ environment_id: environmentId });
}, },
}); });
+25
View File
@@ -0,0 +1,25 @@
import type { EnvironmentVariable } from '@yaakapp-internal/models';
import { environmentsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { useEnvironmentsBreakdown } from './useEnvironmentsBreakdown';
export function useEnvironmentVariables(environmentId: string | null) {
const { baseEnvironment } = useEnvironmentsBreakdown();
const activeEnvironment =
useAtomValue(environmentsAtom).find((e) => e.id === environmentId) ?? null;
return useMemo(() => {
const varMap: Record<string, EnvironmentVariable> = {};
const allVariables = [
...(baseEnvironment?.variables ?? []),
...(activeEnvironment?.variables ?? []),
];
for (const v of allVariables) {
if (!v.enabled || !v.name) continue;
varMap[v.name] = v;
}
return Object.values(varMap);
}, [activeEnvironment, baseEnvironment]);
}
+7 -4
View File
@@ -5,9 +5,12 @@ import { useMemo } from 'react';
export function useEnvironmentsBreakdown() { export function useEnvironmentsBreakdown() {
const allEnvironments = useAtomValue(environmentsAtom); const allEnvironments = useAtomValue(environmentsAtom);
return useMemo(() => { return useMemo(() => {
const baseEnvironment = allEnvironments.find((e) => e.environmentId == null) ?? null; const baseEnvironments = allEnvironments.filter((e) => e.base) ?? [];
const subEnvironments = const subEnvironments = allEnvironments.filter((e) => !e.base) ?? [];
allEnvironments.filter((e) => e.environmentId === (baseEnvironment?.id ?? 'n/a')) ?? [];
return { allEnvironments, baseEnvironment, subEnvironments }; const baseEnvironment = baseEnvironments[0] ?? null;
const otherBaseEnvironments =
baseEnvironments.filter((e) => e.id !== baseEnvironment?.id) ?? [];
return { allEnvironments, baseEnvironment, subEnvironments, otherBaseEnvironments };
}, [allEnvironments]); }, [allEnvironments]);
} }
+2 -3
View File
@@ -1,3 +1,4 @@
import { copyToClipboard } from '../lib/copy';
import { catppuccinMacchiato } from '../lib/theme/themes/catppuccin'; import { catppuccinMacchiato } from '../lib/theme/themes/catppuccin';
import { githubLight } from '../lib/theme/themes/github'; import { githubLight } from '../lib/theme/themes/github';
import { gruvboxDefault } from '../lib/theme/themes/gruvbox'; import { gruvboxDefault } from '../lib/theme/themes/gruvbox';
@@ -6,11 +7,9 @@ import { monokaiProDefault } from '../lib/theme/themes/monokai-pro';
import { rosePineDefault } from '../lib/theme/themes/rose-pine'; import { rosePineDefault } from '../lib/theme/themes/rose-pine';
import { yaakDark } from '../lib/theme/themes/yaak'; import { yaakDark } from '../lib/theme/themes/yaak';
import { getThemeCSS } from '../lib/theme/window'; import { getThemeCSS } from '../lib/theme/window';
import { useCopy } from './useCopy';
import { useListenToTauriEvent } from './useListenToTauriEvent'; import { useListenToTauriEvent } from './useListenToTauriEvent';
export function useGenerateThemeCss() { export function useGenerateThemeCss() {
const copy = useCopy();
useListenToTauriEvent('generate_theme_css', () => { useListenToTauriEvent('generate_theme_css', () => {
const themesCss = [ const themesCss = [
yaakDark, yaakDark,
@@ -23,6 +22,6 @@ export function useGenerateThemeCss() {
] ]
.map(getThemeCSS) .map(getThemeCSS)
.join('\n\n'); .join('\n\n');
copy(themesCss); copyToClipboard(themesCss);
}); });
} }
+1 -12
View File
@@ -1,10 +1,6 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import type { WebsocketConnection, WebsocketEvent } from '@yaakapp-internal/models'; import type { WebsocketConnection, WebsocketEvent } from '@yaakapp-internal/models';
import { import { replaceModelsInStore , websocketConnectionsAtom, websocketEventsAtom } from '@yaakapp-internal/models';
replaceModelsInStore,
websocketConnectionsAtom,
websocketEventsAtom,
} from '@yaakapp-internal/models';
import { atom, useAtomValue } from 'jotai'; import { atom, useAtomValue } from 'jotai';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage'; import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
@@ -35,13 +31,6 @@ export const activeWebsocketConnectionAtom = atom<WebsocketConnection | null>((g
return activeConnections.find((c) => c.id === pinnedConnectionId) ?? activeConnections[0] ?? null; return activeConnections.find((c) => c.id === pinnedConnectionId) ?? activeConnections[0] ?? null;
}); });
export const activeWebsocketEventsAtom = atom(async (get) => {
const connection = get(activeWebsocketConnectionAtom);
return invoke<WebsocketEvent[]>('plugin:yaak-models|websocket_events', {
connectionId: connection?.id ?? 'n/a',
});
});
export function setPinnedWebsocketConnectionId(id: string | null) { export function setPinnedWebsocketConnectionId(id: string | null) {
const activeRequestId = jotaiStore.get(activeRequestIdAtom); const activeRequestId = jotaiStore.get(activeRequestIdAtom);
const activeConnections = jotaiStore.get(activeWebsocketConnectionsAtom); const activeConnections = jotaiStore.get(activeWebsocketConnectionsAtom);
+1 -1
View File
@@ -68,7 +68,7 @@ function isModelRelevant(m: AnyModel) {
if ( if (
m.model !== 'workspace' && m.model !== 'workspace' &&
m.model !== 'folder' && m.model !== 'folder' &&
// m.model !== 'environment' && // Not synced anymore m.model !== 'environment' &&
m.model !== 'http_request' && m.model !== 'http_request' &&
m.model !== 'grpc_request' && m.model !== 'grpc_request' &&
m.model !== 'websocket_request' m.model !== 'websocket_request'
+22
View File
@@ -0,0 +1,22 @@
import { clear, writeText } from '@tauri-apps/plugin-clipboard-manager';
import { showToast } from './toast';
export function copyToClipboard(
text: string | null,
{ disableToast }: { disableToast?: boolean } = {},
) {
if (text == null) {
clear().catch(console.error);
} else {
writeText(text).catch(console.error);
}
if (text != '' && !disableToast) {
showToast({
id: 'copied',
color: 'success',
icon: 'copy',
message: 'Copied to clipboard',
});
}
}