mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-23 17:18:32 +02:00
Ability to sync environments to folder (#207)
This commit is contained in:
@@ -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_%';
|
||||||
20
src-tauri/migrations/20250508161145_public-environments.sql
Normal file
20
src-tauri/migrations/20250508161145_public-environments.sql
Normal 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;
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]) => ({
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use crate::error::Error::{GenericError, IncorrectWorkspaceKey, MissingWorkspaceKey};
|
use crate::error::Error::{
|
||||||
|
GenericError, IncorrectWorkspaceKey, MissingWorkspaceKey, WorkspaceKeyDecryptionError,
|
||||||
|
};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::master_key::MasterKey;
|
use crate::master_key::MasterKey;
|
||||||
use crate::workspace_key::WorkspaceKey;
|
use crate::workspace_key::WorkspaceKey;
|
||||||
@@ -149,8 +151,10 @@ impl EncryptionManager {
|
|||||||
let mkey = self.get_master_key()?;
|
let mkey = self.get_master_key()?;
|
||||||
let decoded_key = BASE64_STANDARD
|
let decoded_key = BASE64_STANDARD
|
||||||
.decode(key.encrypted_key)
|
.decode(key.encrypted_key)
|
||||||
.map_err(|e| GenericError(format!("Failed to decode workspace key {e:?}")))?;
|
.map_err(|e| WorkspaceKeyDecryptionError(e.to_string()))?;
|
||||||
let raw_key = mkey.decrypt(decoded_key.as_slice())?;
|
let raw_key = mkey
|
||||||
|
.decrypt(decoded_key.as_slice())
|
||||||
|
.map_err(|e| WorkspaceKeyDecryptionError(e.to_string()))?;
|
||||||
info!("Got existing workspace key for {workspace_id}");
|
info!("Got existing workspace key for {workspace_id}");
|
||||||
let wkey = WorkspaceKey::from_raw_key(raw_key.as_slice());
|
let wkey = WorkspaceKey::from_raw_key(raw_key.as_slice());
|
||||||
|
|
||||||
|
|||||||
@@ -1,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, };
|
||||||
|
|
||||||
|
|||||||
@@ -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, };
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
@@ -486,11 +486,12 @@ pub struct Environment {
|
|||||||
pub model: String,
|
pub model: String,
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
pub environment_id: Option<String>,
|
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub public: bool,
|
||||||
|
pub base: bool,
|
||||||
pub variables: Vec<EnvironmentVariable>,
|
pub variables: Vec<EnvironmentVariable>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,9 +524,10 @@ impl UpsertModelInfo for Environment {
|
|||||||
Ok(vec![
|
Ok(vec![
|
||||||
(CreatedAt, upsert_date(source, self.created_at)),
|
(CreatedAt, upsert_date(source, self.created_at)),
|
||||||
(UpdatedAt, upsert_date(source, self.updated_at)),
|
(UpdatedAt, upsert_date(source, self.updated_at)),
|
||||||
(EnvironmentId, self.environment_id.into()),
|
|
||||||
(WorkspaceId, self.workspace_id.into()),
|
(WorkspaceId, self.workspace_id.into()),
|
||||||
|
(Base, self.base.into()),
|
||||||
(Name, self.name.trim().into()),
|
(Name, self.name.trim().into()),
|
||||||
|
(Public, self.public.into()),
|
||||||
(Variables, serde_json::to_string(&self.variables)?.into()),
|
(Variables, serde_json::to_string(&self.variables)?.into()),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -533,7 +535,9 @@ impl UpsertModelInfo for Environment {
|
|||||||
fn update_columns() -> Vec<impl IntoIden> {
|
fn update_columns() -> Vec<impl IntoIden> {
|
||||||
vec![
|
vec![
|
||||||
EnvironmentIden::UpdatedAt,
|
EnvironmentIden::UpdatedAt,
|
||||||
|
EnvironmentIden::Base,
|
||||||
EnvironmentIden::Name,
|
EnvironmentIden::Name,
|
||||||
|
EnvironmentIden::Public,
|
||||||
EnvironmentIden::Variables,
|
EnvironmentIden::Variables,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -547,10 +551,11 @@ impl UpsertModelInfo for Environment {
|
|||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
model: row.get("model")?,
|
model: row.get("model")?,
|
||||||
workspace_id: row.get("workspace_id")?,
|
workspace_id: row.get("workspace_id")?,
|
||||||
environment_id: row.get("environment_id")?,
|
|
||||||
created_at: row.get("created_at")?,
|
created_at: row.get("created_at")?,
|
||||||
updated_at: row.get("updated_at")?,
|
updated_at: row.get("updated_at")?,
|
||||||
|
base: row.get("base")?,
|
||||||
name: row.get("name")?,
|
name: row.get("name")?,
|
||||||
|
public: row.get("public")?,
|
||||||
variables: serde_json::from_str(variables.as_str()).unwrap_or_default(),
|
variables: serde_json::from_str(variables.as_str()).unwrap_or_default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use crate::db_context::DbContext;
|
use crate::db_context::DbContext;
|
||||||
use crate::error::Error::GenericError;
|
use crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{Environment, EnvironmentIden};
|
use crate::models::{Environment, EnvironmentIden, EnvironmentVariable};
|
||||||
use crate::util::UpdateSource;
|
use crate::util::UpdateSource;
|
||||||
|
use log::info;
|
||||||
|
|
||||||
impl<'a> DbContext<'a> {
|
impl<'a> DbContext<'a> {
|
||||||
pub fn get_environment(&self, id: &str) -> Result<Environment> {
|
pub fn get_environment(&self, id: &str) -> Result<Environment> {
|
||||||
@@ -10,35 +11,41 @@ impl<'a> DbContext<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_base_environment(&self, workspace_id: &str) -> Result<Environment> {
|
pub fn get_base_environment(&self, workspace_id: &str) -> Result<Environment> {
|
||||||
// Will create base environment if it doesn't exist
|
let environments = self.list_environments_ensure_base(workspace_id)?;
|
||||||
let environments = self.list_environments(workspace_id)?;
|
let base_environments =
|
||||||
|
environments.into_iter().filter(|e| e.base).collect::<Vec<Environment>>();
|
||||||
|
|
||||||
let base_environment = environments
|
if base_environments.len() > 1 {
|
||||||
.into_iter()
|
return Err(MultipleBaseEnvironments(workspace_id.to_string()));
|
||||||
.find(|e| e.environment_id == None && e.workspace_id == workspace_id)
|
}
|
||||||
.ok_or(GenericError(format!("No base environment found for {workspace_id}")))?;
|
|
||||||
|
let base_environment = base_environments.into_iter().find(|e| e.base).ok_or(
|
||||||
|
// Should never happen because one should be created above if it does not exist
|
||||||
|
MissingBaseEnvironment(workspace_id.to_string()),
|
||||||
|
)?;
|
||||||
|
|
||||||
Ok(base_environment)
|
Ok(base_environment)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_environments(&self, workspace_id: &str) -> Result<Vec<Environment>> {
|
/// Lists environments and will create a base environment if one doesn't exist
|
||||||
|
pub fn list_environments_ensure_base(&self, workspace_id: &str) -> Result<Vec<Environment>> {
|
||||||
let mut environments =
|
let mut environments =
|
||||||
self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?;
|
self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?;
|
||||||
|
|
||||||
let base_environment = environments
|
let base_environment = environments.iter().find(|e| e.base);
|
||||||
.iter()
|
|
||||||
.find(|e| e.environment_id == None && e.workspace_id == workspace_id);
|
|
||||||
|
|
||||||
if let None = base_environment {
|
if let None = base_environment {
|
||||||
environments.push(self.upsert_environment(
|
let e = self.upsert_environment(
|
||||||
&Environment {
|
&Environment {
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
environment_id: None,
|
base: true,
|
||||||
name: "Global Variables".to_string(),
|
name: "Global Variables".to_string(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
&UpdateSource::Background,
|
&UpdateSource::Background,
|
||||||
)?);
|
)?;
|
||||||
|
info!("Created base environment {} for {workspace_id}", e.id);
|
||||||
|
environments.push(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(environments)
|
Ok(environments)
|
||||||
@@ -49,12 +56,12 @@ impl<'a> DbContext<'a> {
|
|||||||
environment: &Environment,
|
environment: &Environment,
|
||||||
source: &UpdateSource,
|
source: &UpdateSource,
|
||||||
) -> Result<Environment> {
|
) -> Result<Environment> {
|
||||||
for environment in
|
let deleted_environment = self.delete(environment, source)?;
|
||||||
self.find_many::<Environment>(EnvironmentIden::EnvironmentId, &environment.id, None)?
|
|
||||||
{
|
// Recreate the base environment if we happened to delete it
|
||||||
self.delete_environment(&environment, source)?;
|
self.list_environments_ensure_base(&environment.workspace_id)?;
|
||||||
}
|
|
||||||
self.delete(environment, source)
|
Ok(deleted_environment)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_environment_by_id(&self, id: &str, source: &UpdateSource) -> Result<Environment> {
|
pub fn delete_environment_by_id(&self, id: &str, source: &UpdateSource) -> Result<Environment> {
|
||||||
@@ -69,7 +76,7 @@ impl<'a> DbContext<'a> {
|
|||||||
) -> Result<Environment> {
|
) -> Result<Environment> {
|
||||||
let mut environment = environment.clone();
|
let mut environment = environment.clone();
|
||||||
environment.id = "".to_string();
|
environment.id = "".to_string();
|
||||||
self.upsert(&environment, source)
|
self.upsert_environment(&environment, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn upsert_environment(
|
pub fn upsert_environment(
|
||||||
@@ -77,6 +84,18 @@ impl<'a> DbContext<'a> {
|
|||||||
environment: &Environment,
|
environment: &Environment,
|
||||||
source: &UpdateSource,
|
source: &UpdateSource,
|
||||||
) -> Result<Environment> {
|
) -> Result<Environment> {
|
||||||
self.upsert(environment, source)
|
let cleaned_variables = environment
|
||||||
|
.variables
|
||||||
|
.iter()
|
||||||
|
.filter(|v| !v.name.is_empty() || !v.value.is_empty())
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<EnvironmentVariable>>();
|
||||||
|
self.upsert(
|
||||||
|
&Environment {
|
||||||
|
variables: cleaned_variables,
|
||||||
|
..environment.clone()
|
||||||
|
},
|
||||||
|
source,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::db_context::DbContext;
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{WorkspaceMeta, WorkspaceMetaIden};
|
use crate::models::{WorkspaceMeta, WorkspaceMetaIden};
|
||||||
use crate::util::UpdateSource;
|
use crate::util::UpdateSource;
|
||||||
|
use log::info;
|
||||||
|
|
||||||
impl<'a> DbContext<'a> {
|
impl<'a> DbContext<'a> {
|
||||||
pub fn get_workspace_meta(&self, workspace_id: &str) -> Option<WorkspaceMeta> {
|
pub fn get_workspace_meta(&self, workspace_id: &str) -> Option<WorkspaceMeta> {
|
||||||
@@ -23,10 +24,7 @@ impl<'a> DbContext<'a> {
|
|||||||
Ok(workspace_metas)
|
Ok(workspace_metas)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_or_create_workspace_meta(
|
pub fn get_or_create_workspace_meta(&self, workspace_id: &str) -> Result<WorkspaceMeta> {
|
||||||
&self,
|
|
||||||
workspace_id: &str,
|
|
||||||
) -> Result<WorkspaceMeta> {
|
|
||||||
let workspace_meta = self.get_workspace_meta(workspace_id);
|
let workspace_meta = self.get_workspace_meta(workspace_id);
|
||||||
if let Some(workspace_meta) = workspace_meta {
|
if let Some(workspace_meta) = workspace_meta {
|
||||||
return Ok(workspace_meta);
|
return Ok(workspace_meta);
|
||||||
@@ -37,6 +35,8 @@ impl<'a> DbContext<'a> {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
info!("Creating WorkspaceMeta for {workspace_id}");
|
||||||
|
|
||||||
self.upsert_workspace_meta(&workspace_meta, &UpdateSource::Background)
|
self.upsert_workspace_meta(&workspace_meta, &UpdateSource::Background)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use crate::db_context::DbContext;
|
use crate::db_context::DbContext;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestIden,
|
EnvironmentIden, FolderIden, GrpcRequestIden, HttpRequestIden, WebsocketRequestIden, Workspace,
|
||||||
WebsocketRequest, WebsocketRequestIden, Workspace, WorkspaceIden,
|
WorkspaceIden,
|
||||||
};
|
};
|
||||||
use crate::util::UpdateSource;
|
use crate::util::UpdateSource;
|
||||||
|
|
||||||
@@ -34,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{AnyModel, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo, WebsocketRequest, Workspace, WorkspaceIden};
|
use crate::models::{
|
||||||
|
AnyModel, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo, WebsocketRequest,
|
||||||
|
Workspace, WorkspaceIden,
|
||||||
|
};
|
||||||
use crate::query_manager::QueryManagerExt;
|
use crate::query_manager::QueryManagerExt;
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use log::warn;
|
use log::warn;
|
||||||
@@ -117,14 +120,14 @@ pub struct BatchUpsertResult {
|
|||||||
pub websocket_requests: Vec<WebsocketRequest>,
|
pub websocket_requests: Vec<WebsocketRequest>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_workspace_export_resources<R: Runtime>(
|
pub fn get_workspace_export_resources<R: Runtime>(
|
||||||
app_handle: &AppHandle<R>,
|
app_handle: &AppHandle<R>,
|
||||||
workspace_ids: Vec<&str>,
|
workspace_ids: Vec<&str>,
|
||||||
include_environments: bool,
|
include_private_environments: bool,
|
||||||
) -> Result<WorkspaceExport> {
|
) -> Result<WorkspaceExport> {
|
||||||
let mut data = WorkspaceExport {
|
let mut data = WorkspaceExport {
|
||||||
yaak_version: app_handle.package_info().version.clone().to_string(),
|
yaak_version: app_handle.package_info().version.clone().to_string(),
|
||||||
yaak_schema: 3,
|
yaak_schema: 4,
|
||||||
timestamp: Utc::now().naive_utc(),
|
timestamp: Utc::now().naive_utc(),
|
||||||
resources: BatchUpsertResult {
|
resources: BatchUpsertResult {
|
||||||
workspaces: Vec::new(),
|
workspaces: Vec::new(),
|
||||||
@@ -139,18 +142,19 @@ pub async fn get_workspace_export_resources<R: Runtime>(
|
|||||||
let db = app_handle.db();
|
let db = app_handle.db();
|
||||||
for workspace_id in workspace_ids {
|
for workspace_id in workspace_ids {
|
||||||
data.resources.workspaces.push(db.find_one(WorkspaceIden::Id, workspace_id)?);
|
data.resources.workspaces.push(db.find_one(WorkspaceIden::Id, workspace_id)?);
|
||||||
data.resources.environments.append(&mut db.list_environments(workspace_id)?);
|
data.resources.environments.append(
|
||||||
|
&mut db
|
||||||
|
.list_environments_ensure_base(workspace_id)?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| include_private_environments || e.public)
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
data.resources.folders.append(&mut db.list_folders(workspace_id)?);
|
data.resources.folders.append(&mut db.list_folders(workspace_id)?);
|
||||||
data.resources.http_requests.append(&mut db.list_http_requests(workspace_id)?);
|
data.resources.http_requests.append(&mut db.list_http_requests(workspace_id)?);
|
||||||
data.resources.grpc_requests.append(&mut db.list_grpc_requests(workspace_id)?);
|
data.resources.grpc_requests.append(&mut db.list_grpc_requests(workspace_id)?);
|
||||||
data.resources.websocket_requests.append(&mut db.list_websocket_requests(workspace_id)?);
|
data.resources.websocket_requests.append(&mut db.list_websocket_requests(workspace_id)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nuke environments if we don't want them
|
|
||||||
if !include_environments {
|
|
||||||
data.resources.environments.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type Environment = { model: "environment", id: string, workspaceId: string, environmentId: string | null, createdAt: string, updatedAt: string, name: string, variables: Array<EnvironmentVariable>, };
|
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, };
|
||||||
|
|
||||||
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||||
|
|
||||||
|
|||||||
@@ -1,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, };
|
||||||
|
|
||||||
|
|||||||
@@ -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, };
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -130,4 +130,4 @@ impl TryFrom<AnyModel> for SyncModel {
|
|||||||
};
|
};
|
||||||
Ok(m)
|
Ok(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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}`),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { format } from 'date-fns';
|
|||||||
import { hexy } from 'hexy';
|
import { hexy } from 'hexy';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { useMemo, useRef, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import { useCopy } from '../hooks/useCopy';
|
|
||||||
import { useFormatText } from '../hooks/useFormatText';
|
import { useFormatText } from '../hooks/useFormatText';
|
||||||
import {
|
import {
|
||||||
activeWebsocketConnectionAtom,
|
activeWebsocketConnectionAtom,
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
} from '../hooks/usePinnedWebsocketConnection';
|
} from '../hooks/usePinnedWebsocketConnection';
|
||||||
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
||||||
import { languageFromContentType } from '../lib/contentType';
|
import { languageFromContentType } from '../lib/contentType';
|
||||||
|
import { copyToClipboard } from '../lib/copy';
|
||||||
import { AutoScroller } from './core/AutoScroller';
|
import { AutoScroller } from './core/AutoScroller';
|
||||||
import { Banner } from './core/Banner';
|
import { Banner } from './core/Banner';
|
||||||
import { Button } from './core/Button';
|
import { Button } from './core/Button';
|
||||||
@@ -41,7 +41,6 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
|
|||||||
|
|
||||||
const activeConnection = useAtomValue(activeWebsocketConnectionAtom);
|
const activeConnection = useAtomValue(activeWebsocketConnectionAtom);
|
||||||
const connections = useAtomValue(activeWebsocketConnectionsAtom);
|
const connections = useAtomValue(activeWebsocketConnectionsAtom);
|
||||||
|
|
||||||
const events = useWebsocketEvents(activeConnection?.id ?? null);
|
const events = useWebsocketEvents(activeConnection?.id ?? null);
|
||||||
|
|
||||||
const activeEvent = useMemo(
|
const activeEvent = useMemo(
|
||||||
@@ -63,7 +62,6 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
|
|||||||
|
|
||||||
const language = languageFromContentType(null, message);
|
const language = languageFromContentType(null, message);
|
||||||
const formattedMessage = useFormatText({ language, text: message, pretty: true });
|
const formattedMessage = useFormatText({ language, text: message, pretty: true });
|
||||||
const copy = useCopy();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitLayout
|
<SplitLayout
|
||||||
@@ -151,7 +149,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
|
|||||||
title="Copy message"
|
title="Copy message"
|
||||||
icon="copy"
|
icon="copy"
|
||||||
size="xs"
|
size="xs"
|
||||||
onClick={() => copy(formattedMessage.data ?? '')}
|
onClick={() => copyToClipboard(formattedMessage.data ?? '')}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,15 +29,41 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
|||||||
|
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||||
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
||||||
|
const [key, setKey] = useState<{ key: string | null; error: string | null } | null>(null);
|
||||||
|
|
||||||
if (workspace == null || workspaceMeta == null) {
|
useEffect(() => {
|
||||||
|
if (workspaceMeta == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workspaceMeta?.encryptionKey == null) {
|
||||||
|
setKey({ key: null, error: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
revealWorkspaceKey(workspaceMeta.workspaceId).then(
|
||||||
|
(key) => {
|
||||||
|
setKey({ key, error: null });
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
setKey({ key: null, error: `${err}` });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [setKey, workspaceMeta, workspaceMeta?.encryptionKey]);
|
||||||
|
|
||||||
|
if (key == null || workspace == null || workspaceMeta == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null) {
|
// Prompt for key if it doesn't exist or could not be decrypted
|
||||||
|
if (
|
||||||
|
key.error != null ||
|
||||||
|
(workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null)
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<EnterWorkspaceKey
|
<EnterWorkspaceKey
|
||||||
workspaceMeta={workspaceMeta}
|
workspaceMeta={workspaceMeta}
|
||||||
|
error={key.error}
|
||||||
onEnabled={() => {
|
onEnabled={() => {
|
||||||
onDone?.();
|
onDone?.();
|
||||||
onEnabledEncryption?.();
|
onEnabledEncryption?.();
|
||||||
@@ -46,12 +72,13 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workspaceMeta.encryptionKey) {
|
// Show the key if it exists
|
||||||
|
if (workspaceMeta.encryptionKey && key.key != null) {
|
||||||
const keyRevealer = (
|
const keyRevealer = (
|
||||||
<KeyRevealer
|
<KeyRevealer
|
||||||
disableLabel={justEnabledEncryption}
|
disableLabel={justEnabledEncryption}
|
||||||
defaultShow={justEnabledEncryption}
|
defaultShow={justEnabledEncryption}
|
||||||
workspaceId={workspaceMeta.workspaceId}
|
encryptionKey={key.key}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -63,10 +90,13 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
|||||||
)}
|
)}
|
||||||
{keyRevealer}
|
{keyRevealer}
|
||||||
{onDone && (
|
{onDone && (
|
||||||
<Button color="secondary" onClick={() => {
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
onClick={() => {
|
||||||
onDone();
|
onDone();
|
||||||
onEnabledEncryption?.();
|
onEnabledEncryption?.();
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
Done
|
Done
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -74,6 +104,7 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show button to enable encryption
|
||||||
return (
|
return (
|
||||||
<div className="mb-auto flex flex-col-reverse">
|
<div className="mb-auto flex flex-col-reverse">
|
||||||
<Button
|
<Button
|
||||||
@@ -107,17 +138,23 @@ const setWorkspaceKeyMut = createFastMutation({
|
|||||||
function EnterWorkspaceKey({
|
function EnterWorkspaceKey({
|
||||||
workspaceMeta,
|
workspaceMeta,
|
||||||
onEnabled,
|
onEnabled,
|
||||||
|
error,
|
||||||
}: {
|
}: {
|
||||||
workspaceMeta: WorkspaceMeta;
|
workspaceMeta: WorkspaceMeta;
|
||||||
onEnabled?: () => void;
|
onEnabled?: () => void;
|
||||||
|
error?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const [key, setKey] = useState<string>('');
|
const [key, setKey] = useState<string>('');
|
||||||
return (
|
return (
|
||||||
<VStack space={4}>
|
<VStack space={4} className="w-full">
|
||||||
<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'}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export type PairEditorProps = {
|
|||||||
allowFileValues?: boolean;
|
allowFileValues?: boolean;
|
||||||
allowMultilineValues?: boolean;
|
allowMultilineValues?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
forcedEnvironmentId?: string;
|
||||||
forceUpdateKey?: string;
|
forceUpdateKey?: string;
|
||||||
nameAutocomplete?: GenericCompletionConfig;
|
nameAutocomplete?: GenericCompletionConfig;
|
||||||
nameAutocompleteFunctions?: boolean;
|
nameAutocompleteFunctions?: boolean;
|
||||||
@@ -81,6 +82,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
|
|||||||
allowFileValues,
|
allowFileValues,
|
||||||
allowMultilineValues,
|
allowMultilineValues,
|
||||||
className,
|
className,
|
||||||
|
forcedEnvironmentId,
|
||||||
forceUpdateKey,
|
forceUpdateKey,
|
||||||
nameAutocomplete,
|
nameAutocomplete,
|
||||||
nameAutocompleteFunctions,
|
nameAutocompleteFunctions,
|
||||||
@@ -235,6 +237,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
|
|||||||
allowFileValues={allowFileValues}
|
allowFileValues={allowFileValues}
|
||||||
allowMultilineValues={allowMultilineValues}
|
allowMultilineValues={allowMultilineValues}
|
||||||
className="py-1"
|
className="py-1"
|
||||||
|
forcedEnvironmentId={forcedEnvironmentId}
|
||||||
forceFocusNamePairId={forceFocusNamePairId}
|
forceFocusNamePairId={forceFocusNamePairId}
|
||||||
forceFocusValuePairId={forceFocusValuePairId}
|
forceFocusValuePairId={forceFocusValuePairId}
|
||||||
forceUpdateKey={forceUpdateKey}
|
forceUpdateKey={forceUpdateKey}
|
||||||
@@ -292,6 +295,7 @@ type PairEditorRowProps = {
|
|||||||
PairEditorProps,
|
PairEditorProps,
|
||||||
| 'allowFileValues'
|
| 'allowFileValues'
|
||||||
| 'allowMultilineValues'
|
| 'allowMultilineValues'
|
||||||
|
| 'forcedEnvironmentId'
|
||||||
| 'forceUpdateKey'
|
| 'forceUpdateKey'
|
||||||
| 'nameAutocomplete'
|
| 'nameAutocomplete'
|
||||||
| 'nameAutocompleteVariables'
|
| 'nameAutocompleteVariables'
|
||||||
@@ -311,6 +315,7 @@ function PairEditorRow({
|
|||||||
allowFileValues,
|
allowFileValues,
|
||||||
allowMultilineValues,
|
allowMultilineValues,
|
||||||
className,
|
className,
|
||||||
|
forcedEnvironmentId,
|
||||||
forceFocusNamePairId,
|
forceFocusNamePairId,
|
||||||
forceFocusValuePairId,
|
forceFocusValuePairId,
|
||||||
forceUpdateKey,
|
forceUpdateKey,
|
||||||
@@ -502,6 +507,7 @@ function PairEditorRow({
|
|||||||
size="sm"
|
size="sm"
|
||||||
required={!isLast && !!pair.enabled && !!pair.value}
|
required={!isLast && !!pair.enabled && !!pair.value}
|
||||||
validate={nameValidate}
|
validate={nameValidate}
|
||||||
|
forcedEnvironmentId={forcedEnvironmentId}
|
||||||
forceUpdateKey={forceUpdateKey}
|
forceUpdateKey={forceUpdateKey}
|
||||||
containerClassName={classNames(isLast && 'border-dashed')}
|
containerClassName={classNames(isLast && 'border-dashed')}
|
||||||
defaultValue={pair.name}
|
defaultValue={pair.name}
|
||||||
@@ -549,6 +555,7 @@ function PairEditorRow({
|
|||||||
size="sm"
|
size="sm"
|
||||||
containerClassName={classNames(isLast && 'border-dashed')}
|
containerClassName={classNames(isLast && 'border-dashed')}
|
||||||
validate={valueValidate}
|
validate={valueValidate}
|
||||||
|
forcedEnvironmentId={forcedEnvironmentId}
|
||||||
forceUpdateKey={forceUpdateKey}
|
forceUpdateKey={forceUpdateKey}
|
||||||
defaultValue={pair.value}
|
defaultValue={pair.value}
|
||||||
label="Value"
|
label="Value"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { PairEditor } from './PairEditor';
|
|||||||
|
|
||||||
interface Props extends PairEditorProps {
|
interface Props extends PairEditorProps {
|
||||||
preferenceName: string;
|
preferenceName: string;
|
||||||
|
forcedEnvironmentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PairOrBulkEditor = forwardRef<PairEditorRef, Props>(function PairOrBulkEditor(
|
export const PairOrBulkEditor = forwardRef<PairEditorRef, Props>(function PairOrBulkEditor(
|
||||||
|
|||||||
@@ -7,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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import type { Environment } from '@yaakapp-internal/models';
|
import type { Environment } from '@yaakapp-internal/models';
|
||||||
import { createWorkspaceModel } from '@yaakapp-internal/models';
|
import { createWorkspaceModel } from '@yaakapp-internal/models';
|
||||||
import { jotaiStore } from '../lib/jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { showPrompt } from '../lib/prompt';
|
import { showPrompt } from '../lib/prompt';
|
||||||
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
|
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
|
||||||
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
|
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
|
||||||
import { useFastMutation } from './useFastMutation';
|
import { useFastMutation } from './useFastMutation';
|
||||||
|
|
||||||
export function useCreateEnvironment() {
|
export function useCreateEnvironment() {
|
||||||
return useFastMutation<string, unknown, Environment | null>({
|
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||||
mutationKey: ['create_environment'],
|
|
||||||
|
return useFastMutation<string | null, unknown, Environment | null>({
|
||||||
|
mutationKey: ['create_environment', workspaceId],
|
||||||
mutationFn: async (baseEnvironment) => {
|
mutationFn: async (baseEnvironment) => {
|
||||||
if (baseEnvironment == null) {
|
if (baseEnvironment == null) {
|
||||||
throw new Error('No base environment passed');
|
throw new Error('No base environment passed');
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
if (workspaceId == null) {
|
if (workspaceId == null) {
|
||||||
throw new Error('Cannot create environment when no active workspace');
|
throw new Error('Cannot create environment when no active workspace');
|
||||||
}
|
}
|
||||||
@@ -28,17 +29,21 @@ export function useCreateEnvironment() {
|
|||||||
defaultValue: 'My Environment',
|
defaultValue: 'My Environment',
|
||||||
confirmText: 'Create',
|
confirmText: 'Create',
|
||||||
});
|
});
|
||||||
if (name == null) throw new Error('No name provided to create environment');
|
if (name == null) return null;
|
||||||
|
|
||||||
return createWorkspaceModel({
|
return createWorkspaceModel({
|
||||||
model: 'environment',
|
model: 'environment',
|
||||||
name,
|
name,
|
||||||
variables: [],
|
variables: [],
|
||||||
workspaceId,
|
workspaceId,
|
||||||
environmentId: baseEnvironment.id,
|
base: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async (environmentId) => {
|
onSuccess: async (environmentId) => {
|
||||||
|
if (environmentId == null) {
|
||||||
|
return; // Was not created
|
||||||
|
}
|
||||||
|
|
||||||
setWorkspaceSearchParams({ environment_id: environmentId });
|
setWorkspaceSearchParams({ environment_id: environmentId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
25
src-web/hooks/useEnvironmentVariables.ts
Normal file
25
src-web/hooks/useEnvironmentVariables.ts
Normal 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]);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { copyToClipboard } from '../lib/copy';
|
||||||
import { catppuccinMacchiato } from '../lib/theme/themes/catppuccin';
|
import { catppuccinMacchiato } from '../lib/theme/themes/catppuccin';
|
||||||
import { githubLight } from '../lib/theme/themes/github';
|
import { githubLight } from '../lib/theme/themes/github';
|
||||||
import { gruvboxDefault } from '../lib/theme/themes/gruvbox';
|
import { gruvboxDefault } from '../lib/theme/themes/gruvbox';
|
||||||
@@ -6,11 +7,9 @@ import { monokaiProDefault } from '../lib/theme/themes/monokai-pro';
|
|||||||
import { rosePineDefault } from '../lib/theme/themes/rose-pine';
|
import { rosePineDefault } from '../lib/theme/themes/rose-pine';
|
||||||
import { yaakDark } from '../lib/theme/themes/yaak';
|
import { yaakDark } from '../lib/theme/themes/yaak';
|
||||||
import { getThemeCSS } from '../lib/theme/window';
|
import { getThemeCSS } from '../lib/theme/window';
|
||||||
import { useCopy } from './useCopy';
|
|
||||||
import { useListenToTauriEvent } from './useListenToTauriEvent';
|
import { useListenToTauriEvent } from './useListenToTauriEvent';
|
||||||
|
|
||||||
export function useGenerateThemeCss() {
|
export function useGenerateThemeCss() {
|
||||||
const copy = useCopy();
|
|
||||||
useListenToTauriEvent('generate_theme_css', () => {
|
useListenToTauriEvent('generate_theme_css', () => {
|
||||||
const themesCss = [
|
const themesCss = [
|
||||||
yaakDark,
|
yaakDark,
|
||||||
@@ -23,6 +22,6 @@ export function useGenerateThemeCss() {
|
|||||||
]
|
]
|
||||||
.map(getThemeCSS)
|
.map(getThemeCSS)
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
copy(themesCss);
|
copyToClipboard(themesCss);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,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);
|
||||||
|
|||||||
@@ -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
22
src-web/lib/copy.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user