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

View File

@@ -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_%';

View File

@@ -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;

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)

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]) => ({

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) {

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)]

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")]

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());

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, };

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, };

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());

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,

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(),
}) })
} }

View File

@@ -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,
)
} }
} }

View File

@@ -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)
} }

View File

@@ -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,24 +34,26 @@ 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)
} }

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)
} }

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, };

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, };

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, };

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)]

View File

@@ -130,4 +130,4 @@ impl TryFrom<AnyModel> for SyncModel {
}; };
Ok(m) Ok(m)
} }
} }

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(
} else { model.to_owned(),
DbCandidate::Unmodified(model.to_owned(), existing_sync_state.to_owned()) existing_sync_state.to_owned(),
))
} else {
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. model: model.to_owned(),
if let SyncModel::Environment(_) = model { state: sync_state.to_owned(),
return None; },
}
SyncOp::DbDelete { // DB modified <-> FS missing
model: model.to_owned(),
state: sync_state.to_owned(),
}
}
(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) => {
// Already deleted on FS, but sending it so the SyncState gets dealt with // DB deleted <-> FS missing
SyncOp::FsDelete { // Already deleted on FS, but sending it so the SyncState gets dealt with
state: sync_state.to_owned(), (Some(DbCandidate::Deleted(sync_state)), None) => SyncOp::FsDelete {
fs: None, state: sync_state.to_owned(),
} 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(())

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,8 +22,8 @@ export function CopyButton({ text, ...props }: Props) {
message: 'Failed to copy', message: 'Failed to copy',
}); });
} else { } else {
copy(content); copyToClipboard(content, { disableToast: true });
setCopied(); setCopied();
} }
}} }}
> >

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();
} }
}} }}

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 });

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">
<SidebarButton {[baseEnvironment, ...otherBaseEnvironments].map((e) => (
active={selectedEnvironment?.id == baseEnvironment?.id} <SidebarButton
onClick={() => setSelectedEnvironmentId(null)} key={e.id}
environment={null} active={selectedEnvironment?.id == e.id}
rightSlot={ onClick={() => setSelectedEnvironmentId(e.id)}
<IconButton environment={e}
size="sm" duplicateEnvironment={handleDuplicateEnvironment}
iconSize="md" // Allow deleting base environment if there are multiples
title="Add sub environment" deleteEnvironment={
icon="plus_circle" otherBaseEnvironments.length > 0 ? handleDeleteEnvironment : null
iconClassName="text-text-subtlest group-hover:text-text-subtle" }
className="group" rightSlot={e.public && sharableTooltip}
onClick={handleCreateEnvironment} outerRightSlot={
/> <IconButton
} size="sm"
> iconSize="md"
{baseEnvironment?.name} title="Add sub environment"
</SidebarButton> icon="plus_circle"
iconClassName="text-text-subtlest group-hover:text-text-subtle"
className="group mr-0.5"
onClick={handleCreateEnvironment}
/>
}
>
{resolvedModelName(e)}
</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" <>
icon={valueVisibility.value ? 'eye' : 'eye_closed'} <BadgeButton color="secondary" onClick={() => valueVisibility.set((v) => !v)}>
title={valueVisibility.value ? 'Hide Values' : 'Reveal Values'} {valueVisibility.value ? 'Conceal Values' : 'Reveal Values'}
onClick={() => valueVisibility.set((v) => !v)} </BadgeButton>
/> </>
)} )}
</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,49 +335,88 @@ 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" />,
leftSlot: <Icon icon="pencil" size="sm" />, hidden: environment.base,
onSelect: async () => { onSelect: async () => {
const name = await showPrompt({ const name = await showPrompt({
id: 'rename-environment', id: 'rename-environment',
title: 'Rename Environment', title: 'Rename Environment',
description: ( description: (
<> <>
Enter a new name for <InlineCode>{environment.name}</InlineCode> Enter a new name for <InlineCode>{environment.name}</InlineCode>
</> </>
), ),
label: 'Name', label: 'Name',
confirmText: 'Save', confirmText: 'Save',
placeholder: 'New Name', placeholder: 'New Name',
defaultValue: environment.name, defaultValue: environment.name,
}); });
if (name == null) return; if (name == null) return;
await patchModel(environment, { name }); await patchModel(environment, { name });
},
}, },
{ },
color: 'danger', ...((duplicateEnvironment
label: 'Delete', ? [
leftSlot: <Icon icon="trash" size="sm" />, {
onSelect: async () => { label: 'Duplicate',
await deleteModelWithConfirm(environment); leftSlot: <Icon icon="copy" />,
onDelete?.(); 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',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: () => {
deleteEnvironment(environment);
},
},
]
: []) as DropdownItem[]),
]}
/>
</> </>
); );
} }
const sharableTooltip = (
<IconTooltip
icon="eye"
content="This environment will be included in Directory Sync and data exports"
/>
);

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>

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}
/>
)} )}
</> </>
) : ( ) : (

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}`),
}, },
]} ]}

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>
)} )}

View File

@@ -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">
<Banner color="info"> {error ? (
This workspace contains encrypted values but no key is configured. Please enter the <Banner color="danger">{error}</Banner>
workspace key to access the encrypted data. ) : (
</Banner> <Banner color="info">
This workspace contains encrypted values but no key is configured. Please enter the
workspace key to access the encrypted data.
</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'}

View File

@@ -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,20 +67,23 @@ export function WorkspaceSettingsDialog({ workspaceId, hide }: Props) {
<Separator className="my-4" /> <Separator className="my-4" />
<Button <HStack alignItems="center" justifyContent="between" className="w-full">
onClick={async () => { <Button
const didDelete = await deleteModelWithConfirm(workspace); onClick={async () => {
if (didDelete) { const didDelete = await deleteModelWithConfirm(workspace);
hide(); // Only hide if actually deleted workspace if (didDelete) {
await router.navigate({ to: '/' }); hide(); // Only hide if actually deleted workspace
} await router.navigate({ to: '/' });
}} }
color="danger" }}
variant="border" color="danger"
size="xs" variant="border"
> size="xs"
Delete Workspace >
</Button> Delete Workspace
</Button>
<InlineCode className="select-text cursor-text">{workspaceId}</InlineCode>
</HStack>
</VStack> </VStack>
); );
} }

View File

@@ -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}

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 }}

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>
); );
} }

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';

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);

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',

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>
); );
} }

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'];

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"

View File

@@ -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(

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>
</> </>
); );
} }

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]);
} }

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;
}

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);
}, },
}); });
} }

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 });
}, },
}); });

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]);
}

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]);
} }

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);
}); });
} }

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);

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
src-web/lib/copy.ts Normal file
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',
});
}
}