diff --git a/src-tauri/migrations/20250507140702_remove-ev-sync-states.sql b/src-tauri/migrations/20250507140702_remove-ev-sync-states.sql new file mode 100644 index 00000000..dc860cf1 --- /dev/null +++ b/src-tauri/migrations/20250507140702_remove-ev-sync-states.sql @@ -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_%'; diff --git a/src-tauri/migrations/20250508161145_public-environments.sql b/src-tauri/migrations/20250508161145_public-environments.sql new file mode 100644 index 00000000..48d7a5ec --- /dev/null +++ b/src-tauri/migrations/20250508161145_public-environments.sql @@ -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; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fe99d976..8fcdaf28 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -831,7 +831,6 @@ async fn cmd_import_data( .map(|mut v| { v.id = maybe_gen_id::(v.id.as_str(), &mut id_map); v.workspace_id = maybe_gen_id::(v.workspace_id.as_str(), &mut id_map); - v.environment_id = maybe_gen_id_opt::(v.environment_id, &mut id_map); v }) .collect(); @@ -985,10 +984,10 @@ async fn cmd_export_data( app_handle: AppHandle, export_path: &str, workspace_ids: Vec<&str>, - include_environments: bool, + include_private_environments: bool, ) -> YaakResult<()> { 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() .create(true) .truncate(true) diff --git a/src-tauri/vendored/plugins/importer-insomnia/build/index.js b/src-tauri/vendored/plugins/importer-insomnia/build/index.js index cd965b84..72df1fc4 100644 --- a/src-tauri/vendored/plugins/importer-insomnia/build/index.js +++ b/src-tauri/vendored/plugins/importer-insomnia/build/index.js @@ -7372,7 +7372,7 @@ function importEnvironment(e, workspaceId) { createdAt: e.created ? new Date(e.created).toISOString().replace("Z", "") : void 0, updatedAt: e.updated ? new Date(e.updated).toISOString().replace("Z", "") : void 0, workspaceId: convertId(workspaceId), - environmentId: e.parentId === workspaceId ? null : convertId(e.parentId), + base: e.parentId === workspaceId ? true : false, model: "environment", name: e.name, variables: Object.entries(e.data).map(([name, value]) => ({ diff --git a/src-tauri/vendored/plugins/importer-yaak/build/index.js b/src-tauri/vendored/plugins/importer-yaak/build/index.js index 5c00cd18..2c9c1e21 100644 --- a/src-tauri/vendored/plugins/importer-yaak/build/index.js +++ b/src-tauri/vendored/plugins/importer-yaak/build/index.js @@ -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 }; } function isJSObject(obj) { diff --git a/src-tauri/yaak-crypto/src/encryption.rs b/src-tauri/yaak-crypto/src/encryption.rs index c1213e1d..99f72209 100644 --- a/src-tauri/yaak-crypto/src/encryption.rs +++ b/src-tauri/yaak-crypto/src/encryption.rs @@ -38,7 +38,7 @@ pub(crate) fn decrypt_data(cipher_data: &[u8], key: &Key) -> let (nonce, ciphered_data) = rest.split_at_checked(nonce_bytes).ok_or(InvalidEncryptedData)?; 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)] diff --git a/src-tauri/yaak-crypto/src/error.rs b/src-tauri/yaak-crypto/src/error.rs index e4d18a4b..ca70e97f 100644 --- a/src-tauri/yaak-crypto/src/error.rs +++ b/src-tauri/yaak-crypto/src/error.rs @@ -16,13 +16,16 @@ pub enum Error { #[error("Incorrect workspace key")] IncorrectWorkspaceKey, + #[error("Failed to decrypt workspace key: {0}")] + WorkspaceKeyDecryptionError(String), + #[error("Crypto IO error: {0}")] IoError(#[from] io::Error), - #[error("Failed to encrypt")] + #[error("Failed to encrypt data")] EncryptionError, - #[error("Failed to decrypt")] + #[error("Failed to decrypt data")] DecryptionError, #[error("Invalid encrypted data")] diff --git a/src-tauri/yaak-crypto/src/manager.rs b/src-tauri/yaak-crypto/src/manager.rs index bafa115c..b2c2ec86 100644 --- a/src-tauri/yaak-crypto/src/manager.rs +++ b/src-tauri/yaak-crypto/src/manager.rs @@ -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::master_key::MasterKey; use crate::workspace_key::WorkspaceKey; @@ -149,8 +151,10 @@ impl EncryptionManager { let mkey = self.get_master_key()?; let decoded_key = BASE64_STANDARD .decode(key.encrypted_key) - .map_err(|e| GenericError(format!("Failed to decode workspace key {e:?}")))?; - let raw_key = mkey.decrypt(decoded_key.as_slice())?; + .map_err(|e| WorkspaceKeyDecryptionError(e.to_string()))?; + let raw_key = mkey + .decrypt(decoded_key.as_slice()) + .map_err(|e| WorkspaceKeyDecryptionError(e.to_string()))?; info!("Got existing workspace key for {workspace_id}"); let wkey = WorkspaceKey::from_raw_key(raw_key.as_slice()); diff --git a/src-tauri/yaak-git/bindings/gen_models.ts b/src-tauri/yaak-git/bindings/gen_models.ts index 1f2f72e9..3118c414 100644 --- a/src-tauri/yaak-git/bindings/gen_models.ts +++ b/src-tauri/yaak-git/bindings/gen_models.ts @@ -1,6 +1,6 @@ // 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, }; +export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; diff --git a/src-tauri/yaak-models/bindings/gen_models.ts b/src-tauri/yaak-models/bindings/gen_models.ts index 5e738697..02e4fcf5 100644 --- a/src-tauri/yaak-models/bindings/gen_models.ts +++ b/src-tauri/yaak-models/bindings/gen_models.ts @@ -14,7 +14,7 @@ export type EditorKeymap = "default" | "vim" | "vscode" | "emacs"; 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, }; +export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; diff --git a/src-tauri/yaak-models/src/commands.rs b/src-tauri/yaak-models/src/commands.rs index 66f6af27..3e75d0de 100644 --- a/src-tauri/yaak-models/src/commands.rs +++ b/src-tauri/yaak-models/src/commands.rs @@ -109,7 +109,7 @@ pub(crate) fn workspace_models( // Add the workspace children 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_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_grpc_connections(wid)?.into_iter().map(Into::into).collect()); l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect()); diff --git a/src-tauri/yaak-models/src/error.rs b/src-tauri/yaak-models/src/error.rs index 46281f20..f21a9f0b 100644 --- a/src-tauri/yaak-models/src/error.rs +++ b/src-tauri/yaak-models/src/error.rs @@ -21,6 +21,12 @@ pub enum Error { #[error("Model error: {0}")] 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")] RowNotFound, diff --git a/src-tauri/yaak-models/src/models.rs b/src-tauri/yaak-models/src/models.rs index 2acf7af3..970ed1f9 100644 --- a/src-tauri/yaak-models/src/models.rs +++ b/src-tauri/yaak-models/src/models.rs @@ -486,11 +486,12 @@ pub struct Environment { pub model: String, pub id: String, pub workspace_id: String, - pub environment_id: Option, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub name: String, + pub public: bool, + pub base: bool, pub variables: Vec, } @@ -523,9 +524,10 @@ impl UpsertModelInfo for Environment { Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), - (EnvironmentId, self.environment_id.into()), (WorkspaceId, self.workspace_id.into()), + (Base, self.base.into()), (Name, self.name.trim().into()), + (Public, self.public.into()), (Variables, serde_json::to_string(&self.variables)?.into()), ]) } @@ -533,7 +535,9 @@ impl UpsertModelInfo for Environment { fn update_columns() -> Vec { vec![ EnvironmentIden::UpdatedAt, + EnvironmentIden::Base, EnvironmentIden::Name, + EnvironmentIden::Public, EnvironmentIden::Variables, ] } @@ -547,10 +551,11 @@ impl UpsertModelInfo for Environment { id: row.get("id")?, model: row.get("model")?, workspace_id: row.get("workspace_id")?, - environment_id: row.get("environment_id")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, + base: row.get("base")?, name: row.get("name")?, + public: row.get("public")?, variables: serde_json::from_str(variables.as_str()).unwrap_or_default(), }) } diff --git a/src-tauri/yaak-models/src/queries/environments.rs b/src-tauri/yaak-models/src/queries/environments.rs index 691cf41e..3f1b3bd9 100644 --- a/src-tauri/yaak-models/src/queries/environments.rs +++ b/src-tauri/yaak-models/src/queries/environments.rs @@ -1,8 +1,9 @@ use crate::db_context::DbContext; -use crate::error::Error::GenericError; +use crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments}; use crate::error::Result; -use crate::models::{Environment, EnvironmentIden}; +use crate::models::{Environment, EnvironmentIden, EnvironmentVariable}; use crate::util::UpdateSource; +use log::info; impl<'a> DbContext<'a> { pub fn get_environment(&self, id: &str) -> Result { @@ -10,35 +11,41 @@ impl<'a> DbContext<'a> { } pub fn get_base_environment(&self, workspace_id: &str) -> Result { - // Will create base environment if it doesn't exist - let environments = self.list_environments(workspace_id)?; + let environments = self.list_environments_ensure_base(workspace_id)?; + let base_environments = + environments.into_iter().filter(|e| e.base).collect::>(); - let base_environment = environments - .into_iter() - .find(|e| e.environment_id == None && e.workspace_id == workspace_id) - .ok_or(GenericError(format!("No base environment found for {workspace_id}")))?; + if base_environments.len() > 1 { + return Err(MultipleBaseEnvironments(workspace_id.to_string())); + } + + 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) } - pub fn list_environments(&self, workspace_id: &str) -> Result> { + /// Lists environments and will create a base environment if one doesn't exist + pub fn list_environments_ensure_base(&self, workspace_id: &str) -> Result> { let mut environments = self.find_many::(EnvironmentIden::WorkspaceId, workspace_id, None)?; - let base_environment = environments - .iter() - .find(|e| e.environment_id == None && e.workspace_id == workspace_id); + let base_environment = environments.iter().find(|e| e.base); if let None = base_environment { - environments.push(self.upsert_environment( + let e = self.upsert_environment( &Environment { workspace_id: workspace_id.to_string(), - environment_id: None, + base: true, name: "Global Variables".to_string(), ..Default::default() }, &UpdateSource::Background, - )?); + )?; + info!("Created base environment {} for {workspace_id}", e.id); + environments.push(e); } Ok(environments) @@ -49,12 +56,12 @@ impl<'a> DbContext<'a> { environment: &Environment, source: &UpdateSource, ) -> Result { - for environment in - self.find_many::(EnvironmentIden::EnvironmentId, &environment.id, None)? - { - self.delete_environment(&environment, source)?; - } - self.delete(environment, source) + let deleted_environment = self.delete(environment, source)?; + + // Recreate the base environment if we happened to delete it + self.list_environments_ensure_base(&environment.workspace_id)?; + + Ok(deleted_environment) } pub fn delete_environment_by_id(&self, id: &str, source: &UpdateSource) -> Result { @@ -69,7 +76,7 @@ impl<'a> DbContext<'a> { ) -> Result { let mut environment = environment.clone(); environment.id = "".to_string(); - self.upsert(&environment, source) + self.upsert_environment(&environment, source) } pub fn upsert_environment( @@ -77,6 +84,18 @@ impl<'a> DbContext<'a> { environment: &Environment, source: &UpdateSource, ) -> Result { - self.upsert(environment, source) + let cleaned_variables = environment + .variables + .iter() + .filter(|v| !v.name.is_empty() || !v.value.is_empty()) + .cloned() + .collect::>(); + self.upsert( + &Environment { + variables: cleaned_variables, + ..environment.clone() + }, + source, + ) } } diff --git a/src-tauri/yaak-models/src/queries/workspace_metas.rs b/src-tauri/yaak-models/src/queries/workspace_metas.rs index 0fc23542..8112823e 100644 --- a/src-tauri/yaak-models/src/queries/workspace_metas.rs +++ b/src-tauri/yaak-models/src/queries/workspace_metas.rs @@ -2,6 +2,7 @@ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{WorkspaceMeta, WorkspaceMetaIden}; use crate::util::UpdateSource; +use log::info; impl<'a> DbContext<'a> { pub fn get_workspace_meta(&self, workspace_id: &str) -> Option { @@ -23,10 +24,7 @@ impl<'a> DbContext<'a> { Ok(workspace_metas) } - pub fn get_or_create_workspace_meta( - &self, - workspace_id: &str, - ) -> Result { + pub fn get_or_create_workspace_meta(&self, workspace_id: &str) -> Result { let workspace_meta = self.get_workspace_meta(workspace_id); if let Some(workspace_meta) = workspace_meta { return Ok(workspace_meta); @@ -37,6 +35,8 @@ impl<'a> DbContext<'a> { ..Default::default() }; + info!("Creating WorkspaceMeta for {workspace_id}"); + self.upsert_workspace_meta(&workspace_meta, &UpdateSource::Background) } diff --git a/src-tauri/yaak-models/src/queries/workspaces.rs b/src-tauri/yaak-models/src/queries/workspaces.rs index ef35e9f8..58f773ad 100644 --- a/src-tauri/yaak-models/src/queries/workspaces.rs +++ b/src-tauri/yaak-models/src/queries/workspaces.rs @@ -1,8 +1,8 @@ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{ - Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestIden, - WebsocketRequest, WebsocketRequestIden, Workspace, WorkspaceIden, + EnvironmentIden, FolderIden, GrpcRequestIden, HttpRequestIden, WebsocketRequestIden, Workspace, + WorkspaceIden, }; use crate::util::UpdateSource; @@ -34,24 +34,26 @@ impl<'a> DbContext<'a> { workspace: &Workspace, source: &UpdateSource, ) -> Result { - for m in self.find_many::(HttpRequestIden::WorkspaceId, &workspace.id, None)? { + for m in self.find_many(HttpRequestIden::WorkspaceId, &workspace.id, None)? { self.delete_http_request(&m, source)?; } - - for m in self.find_many::(GrpcRequestIden::WorkspaceId, &workspace.id, None)? { + + for m in self.find_many(GrpcRequestIden::WorkspaceId, &workspace.id, None)? { self.delete_grpc_request(&m, source)?; } - - for m in - self.find_many::(WebsocketRequestIden::FolderId, &workspace.id, None)? - { + + for m in self.find_many(WebsocketRequestIden::FolderId, &workspace.id, None)? { self.delete_websocket_request(&m, source)?; } - - for folder in self.find_many::(FolderIden::WorkspaceId, &workspace.id, None)? { - self.delete_folder(&folder, source)?; + + for m in self.find_many(FolderIden::WorkspaceId, &workspace.id, None)? { + 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) } diff --git a/src-tauri/yaak-models/src/util.rs b/src-tauri/yaak-models/src/util.rs index 409feca1..6c018ada 100644 --- a/src-tauri/yaak-models/src/util.rs +++ b/src-tauri/yaak-models/src/util.rs @@ -1,5 +1,8 @@ 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 chrono::{NaiveDateTime, Utc}; use log::warn; @@ -117,14 +120,14 @@ pub struct BatchUpsertResult { pub websocket_requests: Vec, } -pub async fn get_workspace_export_resources( +pub fn get_workspace_export_resources( app_handle: &AppHandle, workspace_ids: Vec<&str>, - include_environments: bool, + include_private_environments: bool, ) -> Result { let mut data = WorkspaceExport { yaak_version: app_handle.package_info().version.clone().to_string(), - yaak_schema: 3, + yaak_schema: 4, timestamp: Utc::now().naive_utc(), resources: BatchUpsertResult { workspaces: Vec::new(), @@ -139,18 +142,19 @@ pub async fn get_workspace_export_resources( let db = app_handle.db(); for workspace_id in workspace_ids { 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.http_requests.append(&mut db.list_http_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)?); } - // Nuke environments if we don't want them - if !include_environments { - data.resources.environments.clear(); - } - Ok(data) } diff --git a/src-tauri/yaak-plugins/bindings/gen_models.ts b/src-tauri/yaak-plugins/bindings/gen_models.ts index 4b5e5ad2..a9e7794c 100644 --- a/src-tauri/yaak-plugins/bindings/gen_models.ts +++ b/src-tauri/yaak-plugins/bindings/gen_models.ts @@ -1,6 +1,6 @@ // 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, }; +export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; diff --git a/src-tauri/yaak-sync/bindings/gen_models.ts b/src-tauri/yaak-sync/bindings/gen_models.ts index a41a1cc1..8a77ba00 100644 --- a/src-tauri/yaak-sync/bindings/gen_models.ts +++ b/src-tauri/yaak-sync/bindings/gen_models.ts @@ -1,6 +1,6 @@ // 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, }; +export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; diff --git a/src-tauri/yaak-sync/bindings/gen_sync.ts b/src-tauri/yaak-sync/bindings/gen_sync.ts index 9d9b33a4..67ccd6ff 100644 --- a/src-tauri/yaak-sync/bindings/gen_sync.ts +++ b/src-tauri/yaak-sync/bindings/gen_sync.ts @@ -4,4 +4,4 @@ import type { SyncState } from "./gen_models.js"; 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, }; diff --git a/src-tauri/yaak-sync/src/commands.rs b/src-tauri/yaak-sync/src/commands.rs index b5cbe1ad..a5710deb 100644 --- a/src-tauri/yaak-sync/src/commands.rs +++ b/src-tauri/yaak-sync/src/commands.rs @@ -1,7 +1,7 @@ use crate::error::Result; use crate::sync::{ - apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates, get_fs_candidates, - FsCandidate, SyncOp, + apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates, get_fs_candidates, FsCandidate, + SyncOp, }; use crate::watch::{watch_directory, WatchEvent}; use chrono::Utc; @@ -19,9 +19,8 @@ pub async fn calculate( workspace_id: &str, sync_dir: &Path, ) -> Result> { - let db_candidates = get_db_candidates(&app_handle, workspace_id, sync_dir).await?; - let fs_candidates = get_fs_candidates(sync_dir) - .await? + let db_candidates = get_db_candidates(&app_handle, workspace_id, sync_dir)?; + let fs_candidates = get_fs_candidates(sync_dir)? .into_iter() // Only keep items in the same workspace .filter(|fs| fs.model.workspace_id() == workspace_id) @@ -34,7 +33,7 @@ pub async fn calculate( #[command] pub async fn calculate_fs(dir: &Path) -> Result> { 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)) } @@ -45,8 +44,8 @@ pub async fn apply( sync_dir: &Path, workspace_id: &str, ) -> Result<()> { - let sync_state_ops = apply_sync_ops(&app_handle, &workspace_id, sync_dir, sync_ops).await?; - apply_sync_state_ops(&app_handle, workspace_id, sync_dir, sync_state_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) } #[derive(Debug, Clone, Serialize, Deserialize, TS)] diff --git a/src-tauri/yaak-sync/src/models.rs b/src-tauri/yaak-sync/src/models.rs index 50f4c09d..85dc72ac 100644 --- a/src-tauri/yaak-sync/src/models.rs +++ b/src-tauri/yaak-sync/src/models.rs @@ -130,4 +130,4 @@ impl TryFrom for SyncModel { }; Ok(m) } -} \ No newline at end of file +} diff --git a/src-tauri/yaak-sync/src/sync.rs b/src-tauri/yaak-sync/src/sync.rs index add74504..6f3b4fc5 100644 --- a/src-tauri/yaak-sync/src/sync.rs +++ b/src-tauri/yaak-sync/src/sync.rs @@ -1,15 +1,15 @@ use crate::error::Result; use crate::models::SyncModel; use chrono::Utc; -use log::{debug, info, warn}; +use log::{info, warn}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt::{Display, Formatter}; +use std::fs; +use std::fs::File; +use std::io::Write; use std::path::{Path, PathBuf}; use tauri::{AppHandle, Runtime}; -use tokio::fs; -use tokio::fs::File; -use tokio::io::AsyncWriteExt; use ts_rs::TS; use yaak_models::models::{SyncState, WorkspaceMeta}; use yaak_models::query_manager::QueryManagerExt; @@ -41,17 +41,21 @@ pub(crate) enum SyncOp { model: SyncModel, state: SyncState, }, + IgnorePrivate { + model: SyncModel, + }, } impl SyncOp { fn workspace_id(&self) -> String { 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::DbUpdate { state, .. } => state.workspace_id.clone(), 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::DbUpdate { fs, .. } => format!("db_update({})", fs.model.id()), SyncOp::DbDelete { model, .. } => format!("db_delete({})", model.id()), + SyncOp::IgnorePrivate { model } => format!("ignore_private({})", model.id()), } .as_str(), ) @@ -76,8 +81,8 @@ impl Display for SyncOp { #[serde(rename_all = "snake_case")] pub(crate) enum DbCandidate { Added(SyncModel), - Modified(SyncModel, SyncState), Deleted(SyncState), + Modified(SyncModel, SyncState), Unmodified(SyncModel, SyncState), } @@ -85,8 +90,8 @@ impl DbCandidate { fn model_id(&self) -> String { match &self { DbCandidate::Added(m) => m.id(), - DbCandidate::Modified(m, _) => m.id(), DbCandidate::Deleted(s) => s.model_id.clone(), + DbCandidate::Modified(m, _) => m.id(), DbCandidate::Unmodified(m, _) => m.id(), } } @@ -101,16 +106,13 @@ pub(crate) struct FsCandidate { pub(crate) checksum: String, } -pub(crate) async fn get_db_candidates( +pub(crate) fn get_db_candidates( app_handle: &AppHandle, workspace_id: &str, sync_dir: &Path, ) -> Result> { - let models: HashMap<_, _> = workspace_models(app_handle, workspace_id) - .await? - .into_iter() - .map(|m| (m.id(), m)) - .collect(); + let models: HashMap<_, _> = + workspace_models(app_handle, workspace_id)?.into_iter().map(|m| (m.id(), m)).collect(); let sync_states: HashMap<_, _> = app_handle .db() .list_sync_states_for_workspace(workspace_id, sync_dir)? @@ -121,20 +123,42 @@ pub(crate) async fn get_db_candidates( // 1. Add candidates for models (created/modified/unmodified) let mut candidates: Vec = models .values() - .map(|model| { - let existing_sync_state = match sync_states.get(&model.id()) { - Some(s) => s, - None => { - // No sync state yet, so model was just added - return DbCandidate::Added(model.to_owned()); - } - }; + .filter_map(|model| { + match sync_states.get(&model.id()) { + Some(existing_sync_state) => { + // If a sync state exists but the model is now private, treat it as a deletion + match model { + 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; - if updated_since_flush { - DbCandidate::Modified(model.to_owned(), existing_sync_state.to_owned()) - } else { - DbCandidate::Unmodified(model.to_owned(), existing_sync_state.to_owned()) + let updated_since_flush = model.updated_at() > existing_sync_state.flushed_at; + if updated_since_flush { + Some(DbCandidate::Modified( + model.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(); @@ -151,30 +175,28 @@ pub(crate) async fn get_db_candidates( Ok(candidates) } -pub(crate) async fn get_fs_candidates(dir: &Path) -> Result> { +pub(crate) fn get_fs_candidates(dir: &Path) -> Result> { // Ensure the root directory exists - fs::create_dir_all(dir).await?; + fs::create_dir_all(dir)?; let mut candidates = Vec::new(); - let mut entries = fs::read_dir(dir).await?; - while let Some(dir_entry) = entries.next_entry().await? { - if !dir_entry.file_type().await?.is_file() { + let entries = fs::read_dir(dir)?; + for dir_entry in entries { + let dir_entry = match dir_entry { + Ok(v) => v, + Err(_) => continue, + }; + + if !dir_entry.file_type()?.is_file() { continue; }; let path = dir_entry.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(None) => continue, Err(e) => { - warn!("Failed to read sync file {e}"); + warn!("Failed to parse sync file {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)) { (None, None) => return None, // Can never happen (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 - // ignore the cleaning we did above of any environments that were written - // to disk in the 2025.1 betas. - if let SyncModel::Environment(_) = model { - return None; - } - SyncOp::DbDelete { - model: model.to_owned(), - state: sync_state.to_owned(), - } - } + + // DB unchanged <-> FS missing + (Some(DbCandidate::Unmodified(model, sync_state)), None) => SyncOp::DbDelete { + model: model.to_owned(), + state: sync_state.to_owned(), + }, + + // DB modified <-> FS missing (Some(DbCandidate::Modified(model, sync_state)), None) => SyncOp::FsUpdate { model: model.to_owned(), state: sync_state.to_owned(), }, + + // DB added <-> FS missing (Some(DbCandidate::Added(model)), None) => SyncOp::FsCreate { model: model.to_owned(), }, - (Some(DbCandidate::Deleted(sync_state)), None) => { - // Already deleted on FS, but sending it so the SyncState gets dealt with - SyncOp::FsDelete { - state: sync_state.to_owned(), - fs: None, - } - } + + // DB deleted <-> FS missing + // Already deleted on FS, but sending it so the SyncState gets dealt with + (Some(DbCandidate::Deleted(sync_state)), None) => SyncOp::FsDelete { + state: sync_state.to_owned(), + fs: None, + }, + + // DB unchanged <-> FS exists (Some(DbCandidate::Unmodified(_, sync_state)), Some(fs_candidate)) => { if sync_state.checksum == fs_candidate.checksum { 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)) => { if sync_state.checksum == fs_candidate.checksum { SyncOp::FsUpdate { @@ -255,25 +279,29 @@ pub(crate) fn compute_sync_ops( state: sync_state.to_owned(), } } 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 { state: sync_state.to_owned(), fs: fs_candidate.to_owned(), } } else { - // CONFLICT! Write to FS if db model is newer + // CONFLICT! Write to FS if the db model is newer SyncOp::FsUpdate { model: model.to_owned(), state: sync_state.to_owned(), } } } + + // DB added <-> FS anything (Some(DbCandidate::Added(model)), Some(_)) => { // This would be super rare (impossible?), so let's follow the user's intention SyncOp::FsCreate { model: model.to_owned(), } } + + // DB deleted <-> FS exists (Some(DbCandidate::Deleted(sync_state)), Some(fs_candidate)) => SyncOp::FsDelete { state: sync_state.to_owned(), fs: Some(fs_candidate.to_owned()), @@ -284,12 +312,19 @@ pub(crate) fn compute_sync_ops( .collect() } -async fn workspace_models( +fn workspace_models( app_handle: &AppHandle, workspace_id: &str, ) -> Result> { - let resources = - get_workspace_export_resources(app_handle, vec![workspace_id], true).await?.resources; + // We want to include private environments here so that we can take them into account during + // 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 = match workspace { @@ -318,7 +353,7 @@ async fn workspace_models( Ok(sync_models) } -pub(crate) async fn apply_sync_ops( +pub(crate) fn apply_sync_ops( app_handle: &AppHandle, workspace_id: &str, sync_dir: &Path, @@ -328,13 +363,14 @@ pub(crate) async fn apply_sync_ops( return Ok(Vec::new()); } - debug!( + info!( "Applying sync ops {}", sync_ops.iter().map(|op| op.to_string()).collect::>().join(", ") ); + let mut sync_state_ops = 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 http_requests_to_upsert = Vec::new(); let mut grpc_requests_to_upsert = Vec::new(); @@ -351,8 +387,8 @@ pub(crate) async fn apply_sync_ops( let rel_path = derive_model_filename(&model); let abs_path = sync_dir.join(rel_path.clone()); let (content, checksum) = model.to_file_contents(&rel_path)?; - let mut f = File::create(&abs_path).await?; - f.write_all(&content).await?; + let mut f = File::create(&abs_path)?; + f.write_all(&content)?; SyncStateOp::Create { model_id: model.id(), checksum, @@ -364,8 +400,8 @@ pub(crate) async fn apply_sync_ops( let rel_path = Path::new(&state.rel_path); let abs_path = Path::new(&state.sync_dir).join(&rel_path); let (content, checksum) = model.to_file_contents(&rel_path)?; - let mut f = File::create(&abs_path).await?; - f.write_all(&content).await?; + let mut f = File::create(&abs_path)?; + f.write_all(&content)?; SyncStateOp::Update { state: state.to_owned(), checksum, @@ -383,7 +419,7 @@ pub(crate) async fn apply_sync_ops( // Always delete the existing path let rel_path = Path::new(&state.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 { state: state.to_owned(), } @@ -395,14 +431,12 @@ pub(crate) async fn apply_sync_ops( // Push updates to arrays so we can do them all in a single // batch upsert to make foreign keys happy 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::HttpRequest(m) => http_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), - - // TODO: Handle environments in sync - SyncModel::Environment(_) => {} + SyncModel::Workspace(m) => workspaces_to_upsert.push(m), }; SyncStateOp::Create { model_id, @@ -414,14 +448,12 @@ pub(crate) async fn apply_sync_ops( // Push updates to arrays so we can do them all in a single // batch upsert to make foreign keys happy 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::HttpRequest(m) => http_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), - - // TODO: Handle environments in sync - SyncModel::Environment(_) => {} + SyncModel::Workspace(m) => workspaces_to_upsert.push(m), } SyncStateOp::Update { state: state.to_owned(), @@ -435,6 +467,7 @@ pub(crate) async fn apply_sync_ops( state: state.to_owned(), } } + SyncOp::IgnorePrivate { .. } => SyncStateOp::NoOp, }); } @@ -497,9 +530,10 @@ pub(crate) enum SyncStateOp { Delete { state: SyncState, }, + NoOp, } -pub(crate) async fn apply_sync_state_ops( +pub(crate) fn apply_sync_state_ops( app_handle: &AppHandle, workspace_id: &str, sync_dir: &Path, @@ -540,6 +574,9 @@ pub(crate) async fn apply_sync_state_ops( SyncStateOp::Delete { state } => { app_handle.db().delete_sync_state(&state)?; } + SyncStateOp::NoOp => { + // Nothing + } } } Ok(()) diff --git a/src-web/components/CopyButton.tsx b/src-web/components/CopyButton.tsx index e5a892f9..2c262429 100644 --- a/src-web/components/CopyButton.tsx +++ b/src-web/components/CopyButton.tsx @@ -1,5 +1,5 @@ -import { useCopy } from '../hooks/useCopy'; import { useTimedBoolean } from '../hooks/useTimedBoolean'; +import { copyToClipboard } from '../lib/copy'; import { showToast } from '../lib/toast'; import type { ButtonProps } from './core/Button'; import { Button } from './core/Button'; @@ -9,7 +9,6 @@ interface Props extends Omit { } export function CopyButton({ text, ...props }: Props) { - const copy = useCopy({ disableToast: true }); const [copied, setCopied] = useTimedBoolean(); return ( - {rightSlot} + {outerRightSlot} - {environment != null && ( - setShowContextMenu(null)} - items={[ - { - label: 'Rename', - leftSlot: , - onSelect: async () => { - const name = await showPrompt({ - id: 'rename-environment', - title: 'Rename Environment', - description: ( - <> - Enter a new name for {environment.name} - - ), - label: 'Name', - confirmText: 'Save', - placeholder: 'New Name', - defaultValue: environment.name, - }); - if (name == null) return; - await patchModel(environment, { name }); - }, + setShowContextMenu(null)} + items={[ + { + label: 'Rename', + leftSlot: , + hidden: environment.base, + onSelect: async () => { + const name = await showPrompt({ + id: 'rename-environment', + title: 'Rename Environment', + description: ( + <> + Enter a new name for {environment.name} + + ), + label: 'Name', + confirmText: 'Save', + placeholder: 'New Name', + defaultValue: environment.name, + }); + if (name == null) return; + await patchModel(environment, { name }); }, - { - color: 'danger', - label: 'Delete', - leftSlot: , - onSelect: async () => { - await deleteModelWithConfirm(environment); - onDelete?.(); - }, + }, + ...((duplicateEnvironment + ? [ + { + label: 'Duplicate', + leftSlot: , + onSelect: () => { + duplicateEnvironment?.(environment); + }, + }, + ] + : []) as DropdownItem[]), + { + label: `Make ${environment.public ? 'Private' : 'Sharable'}`, + leftSlot: , + rightSlot: ( + + 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: , + onSelect: () => { + deleteEnvironment(environment); + }, + }, + ] + : []) as DropdownItem[]), + ]} + /> ); } + +const sharableTooltip = ( + +); diff --git a/src-web/components/ExportDataDialog.tsx b/src-web/components/ExportDataDialog.tsx index 5b01c2fb..35d8dd2b 100644 --- a/src-web/components/ExportDataDialog.tsx +++ b/src-web/components/ExportDataDialog.tsx @@ -1,5 +1,5 @@ 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 { useAtomValue } from 'jotai'; import { useCallback, useMemo, useState } from 'react'; @@ -41,12 +41,12 @@ function ExportDataDialogContent({ allWorkspaces: Workspace[]; activeWorkspace: Workspace; }) { - const [includeEnvironments, setIncludeEnvironments] = useState(false); + const [includePrivateEnvironments, setIncludePrivateEnvironments] = useState(false); const [selectedWorkspaces, setSelectedWorkspaces] = useState>({ [activeWorkspace.id]: true, }); - // Put active workspace first + // Put the active workspace first const workspaces = useMemo( () => [activeWorkspace, ...allWorkspaces.filter((w) => w.id !== activeWorkspace.id)], [activeWorkspace, allWorkspaces], @@ -73,11 +73,11 @@ function ExportDataDialogContent({ await invokeCmd('cmd_export_data', { workspaceIds: ids, exportPath, - includeEnvironments: includeEnvironments, + includePrivateEnvironments: includePrivateEnvironments, }); onHide(); onSuccess(exportPath); - }, [includeEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]); + }, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]); const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]); const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length; @@ -129,9 +129,10 @@ function ExportDataDialogContent({ Extra Settings
diff --git a/src-web/components/GrpcResponsePane.tsx b/src-web/components/GrpcResponsePane.tsx index 106e1d03..157048fc 100644 --- a/src-web/components/GrpcResponsePane.tsx +++ b/src-web/components/GrpcResponsePane.tsx @@ -1,10 +1,9 @@ import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models'; import classNames from 'classnames'; import { format } from 'date-fns'; -import { useAtomValue , useSetAtom } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import type { CSSProperties } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { useCopy } from '../hooks/useCopy'; import { activeGrpcConnectionAtom, activeGrpcConnections, @@ -12,12 +11,14 @@ import { useGrpcEvents, } from '../hooks/usePinnedGrpcConnection'; import { useStateWithDeps } from '../hooks/useStateWithDeps'; +import { copyToClipboard } from '../lib/copy'; import { AutoScroller } from './core/AutoScroller'; import { Banner } from './core/Banner'; import { Button } from './core/Button'; +import { Editor } from './core/Editor/Editor'; +import { HotKeyList } from './core/HotKeyList'; import { Icon } from './core/Icon'; import { IconButton } from './core/IconButton'; -import { JsonAttributeTree } from './core/JsonAttributeTree'; import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; import { LoadingIcon } from './core/LoadingIcon'; import { Separator } from './core/Separator'; @@ -25,7 +26,6 @@ import { SplitLayout } from './core/SplitLayout'; import { HStack, VStack } from './core/Stacks'; import { EmptyStateText } from './EmptyStateText'; import { RecentGrpcConnectionsDropdown } from './RecentGrpcConnectionsDropdown'; -import { HotKeyList } from './core/HotKeyList'; interface Props { style?: CSSProperties; @@ -48,7 +48,6 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) { const activeConnection = useAtomValue(activeGrpcConnectionAtom); const events = useGrpcEvents(activeConnection?.id ?? null); const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom); - const copy = useCopy(); const activeEvent = useMemo( () => events.find((m) => m.id === activeEventId) ?? null, @@ -136,7 +135,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) { title="Copy message" icon="copy" size="xs" - onClick={() => copy(activeEvent.content)} + onClick={() => copyToClipboard(activeEvent.content)} /> {!showLarge && activeEvent.content.length > 1000 * 1000 ? ( @@ -161,7 +160,13 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) { ) : ( - + )} ) : ( diff --git a/src-web/components/SettingsDropdown.tsx b/src-web/components/SettingsDropdown.tsx index a7d00b4e..1ede3162 100644 --- a/src-web/components/SettingsDropdown.tsx +++ b/src-web/components/SettingsDropdown.tsx @@ -74,13 +74,13 @@ export function SettingsDropdown() { { label: 'Feedback', leftSlot: , - rightSlot: , + rightSlot: , onSelect: () => openUrl('https://yaak.app/feedback'), }, { label: 'Changelog', leftSlot: , - rightSlot: , + rightSlot: , onSelect: () => openUrl(`https://yaak.app/changelog/${appInfo.version}`), }, ]} diff --git a/src-web/components/WebsocketResponsePane.tsx b/src-web/components/WebsocketResponsePane.tsx index f7900110..4464031d 100644 --- a/src-web/components/WebsocketResponsePane.tsx +++ b/src-web/components/WebsocketResponsePane.tsx @@ -4,7 +4,6 @@ import { format } from 'date-fns'; import { hexy } from 'hexy'; import { useAtomValue } from 'jotai'; import { useMemo, useRef, useState } from 'react'; -import { useCopy } from '../hooks/useCopy'; import { useFormatText } from '../hooks/useFormatText'; import { activeWebsocketConnectionAtom, @@ -14,6 +13,7 @@ import { } from '../hooks/usePinnedWebsocketConnection'; import { useStateWithDeps } from '../hooks/useStateWithDeps'; import { languageFromContentType } from '../lib/contentType'; +import { copyToClipboard } from '../lib/copy'; import { AutoScroller } from './core/AutoScroller'; import { Banner } from './core/Banner'; import { Button } from './core/Button'; @@ -41,7 +41,6 @@ export function WebsocketResponsePane({ activeRequest }: Props) { const activeConnection = useAtomValue(activeWebsocketConnectionAtom); const connections = useAtomValue(activeWebsocketConnectionsAtom); - const events = useWebsocketEvents(activeConnection?.id ?? null); const activeEvent = useMemo( @@ -63,7 +62,6 @@ export function WebsocketResponsePane({ activeRequest }: Props) { const language = languageFromContentType(null, message); const formattedMessage = useFormatText({ language, text: message, pretty: true }); - const copy = useCopy(); return ( copy(formattedMessage.data ?? '')} + onClick={() => copyToClipboard(formattedMessage.data ?? '')} /> )} diff --git a/src-web/components/WorkspaceEncryptionSetting.tsx b/src-web/components/WorkspaceEncryptionSetting.tsx index bdc4ff08..8373c855 100644 --- a/src-web/components/WorkspaceEncryptionSetting.tsx +++ b/src-web/components/WorkspaceEncryptionSetting.tsx @@ -29,15 +29,41 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn const workspace = useAtomValue(activeWorkspaceAtom); 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; } - 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 ( { onDone?.(); 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 = ( ); return ( @@ -63,10 +90,13 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn )} {keyRevealer} {onDone && ( - )} @@ -74,6 +104,7 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn ); } + // Show button to enable encryption return (
+ + + {workspaceId} + ); } diff --git a/src-web/components/core/BulkPairEditor.tsx b/src-web/components/core/BulkPairEditor.tsx index b956af82..9c67bcf4 100644 --- a/src-web/components/core/BulkPairEditor.tsx +++ b/src-web/components/core/BulkPairEditor.tsx @@ -11,6 +11,7 @@ export function BulkPairEditor({ namePlaceholder, valuePlaceholder, forceUpdateKey, + forcedEnvironmentId, stateKey, }: Props) { const pairsText = useMemo(() => { @@ -36,6 +37,7 @@ export function BulkPairEditor({ autocompleteFunctions autocompleteVariables stateKey={`bulk_pair.${stateKey}`} + forcedEnvironmentId={forcedEnvironmentId} forceUpdateKey={forceUpdateKey} placeholder={`${namePlaceholder ?? 'name'}: ${valuePlaceholder ?? 'value'}`} defaultValue={pairsText} diff --git a/src-web/components/core/Dialog.tsx b/src-web/components/core/Dialog.tsx index c2604ae9..ae0df4f5 100644 --- a/src-web/components/core/Dialog.tsx +++ b/src-web/components/core/Dialog.tsx @@ -2,7 +2,6 @@ import classNames from 'classnames'; import * as m from 'motion/react-m'; import type { ReactNode } from 'react'; import { useMemo } from 'react'; -import { useKey } from 'react-use'; import { Overlay } from '../Overlay'; import { Heading } from './Heading'; import { IconButton } from './IconButton'; @@ -42,18 +41,9 @@ export function Dialog({ [description], ); - useKey( - 'Escape', - () => { - if (!open) return; - onClose?.(); - }, - {}, - [open], - ); - return ( + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
{ + // 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(); + } + }} > - + {children} + ); } diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index 99fefc40..5d82c7ae 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; -import * as m from 'motion/react-m'; import { atom } from 'jotai'; +import * as m from 'motion/react-m'; import type { CSSProperties, FocusEvent as ReactFocusEvent, @@ -34,9 +34,9 @@ import { Overlay } from '../Overlay'; import { Button } from './Button'; import { HotKey } from './HotKey'; import { Icon } from './Icon'; +import { LoadingIcon } from './LoadingIcon'; import { Separator } from './Separator'; import { HStack, VStack } from './Stacks'; -import { LoadingIcon } from './LoadingIcon'; export type DropdownItemSeparator = { type: 'separator'; diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index b82cb6bd..86b89812 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -26,7 +26,8 @@ import { useMemo, useRef, } from 'react'; -import { useActiveEnvironmentVariables } from '../../../hooks/useActiveEnvironmentVariables'; +import { activeEnvironmentIdAtom } from '../../../hooks/useActiveEnvironment'; +import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables'; import { useRequestEditor } from '../../../hooks/useRequestEditor'; import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions'; import { showDialog } from '../../../lib/dialog'; @@ -69,6 +70,7 @@ export interface EditorProps { disableTabIndent?: boolean; disabled?: boolean; extraExtensions?: Extension[]; + forcedEnvironmentId?: string; forceUpdateKey?: string | number; format?: (v: string) => Promise; heightMode?: 'auto' | 'full'; @@ -108,6 +110,7 @@ export const Editor = forwardRef(function E disableTabIndent, disabled, extraExtensions, + forcedEnvironmentId, forceUpdateKey, format, heightMode, @@ -130,7 +133,9 @@ export const Editor = forwardRef(function E ) { 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 useTemplating = !!(autocompleteFunctions || autocompleteVariables || autocomplete); diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index 87c8c558..aba15f74 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -44,6 +44,7 @@ const icons = { eye_closed: lucide.EyeOffIcon, file_code: lucide.FileCodeIcon, filter: lucide.FilterIcon, + flame: lucide.FlameIcon, flask: lucide.FlaskConicalIcon, folder: lucide.FolderIcon, folder_git: lucide.FolderGitIcon, @@ -138,7 +139,7 @@ export const Icon = memo(function Icon({ size === 'xs' && 'h-3 w-3', size === '2xs' && 'h-2.5 w-2.5', color === 'default' && 'inherit', - color === 'danger' && 'text-danger!', + color === 'danger' && 'text-danger', color === 'warning' && 'text-warning', color === 'notice' && 'text-notice', color === 'info' && 'text-info', diff --git a/src-web/components/core/IconTooltip.tsx b/src-web/components/core/IconTooltip.tsx index 38ec1b66..c6072ec1 100644 --- a/src-web/components/core/IconTooltip.tsx +++ b/src-web/components/core/IconTooltip.tsx @@ -7,13 +7,25 @@ import { Tooltip } from './Tooltip'; type Props = Omit & { icon?: IconProps['icon']; iconSize?: IconProps['size']; + iconColor?: IconProps['color']; className?: string; }; -export function IconTooltip({ content, icon = 'info', iconSize, ...tooltipProps }: Props) { +export function IconTooltip({ + content, + icon = 'info', + iconColor, + iconSize, + ...tooltipProps +}: Props) { return ( - + ); } diff --git a/src-web/components/core/Input.tsx b/src-web/components/core/Input.tsx index 1f33eaa8..95c270c9 100644 --- a/src-web/components/core/Input.tsx +++ b/src-web/components/core/Input.tsx @@ -30,11 +30,13 @@ import { Icon } from './Icon'; import { IconButton } from './IconButton'; import { Label } from './Label'; import { HStack } from './Stacks'; +import { copyToClipboard } from '../../lib/copy'; export type InputProps = Pick< EditorProps, | 'language' | 'autocomplete' + | 'forcedEnvironmentId' | 'forceUpdateKey' | 'disabled' | 'autoFocus' @@ -387,19 +389,32 @@ function EncryptionInput({ const dropdownItems = useMemo( () => [ { - label: state.obscured ? 'Reveal value' : 'Conceal value', + label: state.obscured ? 'Reveal' : 'Conceal', disabled: isEncryptionEnabled && state.fieldType === 'text', leftSlot: , onSelect: () => setState((s) => ({ ...s, obscured: !s.obscured })), }, + { + label: 'Copy', + leftSlot: , + hidden: !state.value, + onSelect: () => copyToClipboard(state.value ?? ''), + }, { type: 'separator' }, { - label: state.fieldType === 'text' ? 'Encrypt Value' : 'Decrypt Value', + label: state.fieldType === 'text' ? 'Encrypt Field' : 'Decrypt Field', leftSlot: , 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']; diff --git a/src-web/components/core/PairEditor.tsx b/src-web/components/core/PairEditor.tsx index 7d494654..56a7da2e 100644 --- a/src-web/components/core/PairEditor.tsx +++ b/src-web/components/core/PairEditor.tsx @@ -41,6 +41,7 @@ export type PairEditorProps = { allowFileValues?: boolean; allowMultilineValues?: boolean; className?: string; + forcedEnvironmentId?: string; forceUpdateKey?: string; nameAutocomplete?: GenericCompletionConfig; nameAutocompleteFunctions?: boolean; @@ -81,6 +82,7 @@ export const PairEditor = forwardRef(function Pa allowFileValues, allowMultilineValues, className, + forcedEnvironmentId, forceUpdateKey, nameAutocomplete, nameAutocompleteFunctions, @@ -235,6 +237,7 @@ export const PairEditor = forwardRef(function Pa allowFileValues={allowFileValues} allowMultilineValues={allowMultilineValues} className="py-1" + forcedEnvironmentId={forcedEnvironmentId} forceFocusNamePairId={forceFocusNamePairId} forceFocusValuePairId={forceFocusValuePairId} forceUpdateKey={forceUpdateKey} @@ -292,6 +295,7 @@ type PairEditorRowProps = { PairEditorProps, | 'allowFileValues' | 'allowMultilineValues' + | 'forcedEnvironmentId' | 'forceUpdateKey' | 'nameAutocomplete' | 'nameAutocompleteVariables' @@ -311,6 +315,7 @@ function PairEditorRow({ allowFileValues, allowMultilineValues, className, + forcedEnvironmentId, forceFocusNamePairId, forceFocusValuePairId, forceUpdateKey, @@ -502,6 +507,7 @@ function PairEditorRow({ size="sm" required={!isLast && !!pair.enabled && !!pair.value} validate={nameValidate} + forcedEnvironmentId={forcedEnvironmentId} forceUpdateKey={forceUpdateKey} containerClassName={classNames(isLast && 'border-dashed')} defaultValue={pair.name} @@ -549,6 +555,7 @@ function PairEditorRow({ size="sm" containerClassName={classNames(isLast && 'border-dashed')} validate={valueValidate} + forcedEnvironmentId={forcedEnvironmentId} forceUpdateKey={forceUpdateKey} defaultValue={pair.value} label="Value" diff --git a/src-web/components/core/PairOrBulkEditor.tsx b/src-web/components/core/PairOrBulkEditor.tsx index 05e864ec..be3c83cf 100644 --- a/src-web/components/core/PairOrBulkEditor.tsx +++ b/src-web/components/core/PairOrBulkEditor.tsx @@ -8,6 +8,7 @@ import { PairEditor } from './PairEditor'; interface Props extends PairEditorProps { preferenceName: string; + forcedEnvironmentId?: string; } export const PairOrBulkEditor = forwardRef(function PairOrBulkEditor( diff --git a/src-web/components/core/Tooltip.tsx b/src-web/components/core/Tooltip.tsx index ec96ea8b..1702c00b 100644 --- a/src-web/components/core/Tooltip.tsx +++ b/src-web/components/core/Tooltip.tsx @@ -7,6 +7,7 @@ import { Portal } from '../Portal'; export interface TooltipProps { children: ReactNode; content: ReactNode; + tabIndex?: number, size?: 'md' | 'lg'; } @@ -18,7 +19,7 @@ const hiddenStyles: CSSProperties = { opacity: 0, }; -export function Tooltip({ children, content, size = 'md' }: TooltipProps) { +export function Tooltip({ children, content, tabIndex, size = 'md' }: TooltipProps) { const [isOpen, setIsOpen] = useState(); const triggerRef = useRef(null); const tooltipRef = useRef(null); @@ -89,11 +90,12 @@ export function Tooltip({ children, content, size = 'md' }: TooltipProps) {
- + ); } diff --git a/src-web/hooks/useActiveEnvironmentVariables.ts b/src-web/hooks/useActiveEnvironmentVariables.ts index 8f8632f2..a13ca905 100644 --- a/src-web/hooks/useActiveEnvironmentVariables.ts +++ b/src-web/hooks/useActiveEnvironmentVariables.ts @@ -1,24 +1,8 @@ -import type { EnvironmentVariable } from '@yaakapp-internal/models'; import { useAtomValue } from 'jotai'; -import { useMemo } from 'react'; import { activeEnvironmentAtom } from './useActiveEnvironment'; -import { useEnvironmentsBreakdown } from './useEnvironmentsBreakdown'; +import { useEnvironmentVariables } from './useEnvironmentVariables'; export function useActiveEnvironmentVariables() { - const { baseEnvironment } = useEnvironmentsBreakdown(); const activeEnvironment = useAtomValue(activeEnvironmentAtom); - return useMemo(() => { - const varMap: Record = {}; - 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]); + return useEnvironmentVariables(activeEnvironment?.id ?? null); } diff --git a/src-web/hooks/useCopy.ts b/src-web/hooks/useCopy.ts deleted file mode 100644 index e648a5bf..00000000 --- a/src-web/hooks/useCopy.ts +++ /dev/null @@ -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; -} diff --git a/src-web/hooks/useCopyHttpResponse.ts b/src-web/hooks/useCopyHttpResponse.ts index c5a5017f..39098176 100644 --- a/src-web/hooks/useCopyHttpResponse.ts +++ b/src-web/hooks/useCopyHttpResponse.ts @@ -1,15 +1,14 @@ -import { useFastMutation } from './useFastMutation'; import type { HttpResponse } from '@yaakapp-internal/models'; -import { useCopy } from './useCopy'; +import { copyToClipboard } from '../lib/copy'; import { getResponseBodyText } from '../lib/responseBody'; +import { useFastMutation } from './useFastMutation'; export function useCopyHttpResponse(response: HttpResponse) { - const copy = useCopy(); return useFastMutation({ mutationKey: ['copy_http_response', response.id], async mutationFn() { const body = await getResponseBodyText(response); - copy(body); + copyToClipboard(body); }, }); } diff --git a/src-web/hooks/useCreateEnvironment.ts b/src-web/hooks/useCreateEnvironment.ts index e03904ea..34f4a923 100644 --- a/src-web/hooks/useCreateEnvironment.ts +++ b/src-web/hooks/useCreateEnvironment.ts @@ -1,20 +1,21 @@ import type { Environment } 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 { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams'; import { activeWorkspaceIdAtom } from './useActiveWorkspace'; import { useFastMutation } from './useFastMutation'; export function useCreateEnvironment() { - return useFastMutation({ - mutationKey: ['create_environment'], + const workspaceId = useAtomValue(activeWorkspaceIdAtom); + + return useFastMutation({ + mutationKey: ['create_environment', workspaceId], mutationFn: async (baseEnvironment) => { if (baseEnvironment == null) { throw new Error('No base environment passed'); } - const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); if (workspaceId == null) { throw new Error('Cannot create environment when no active workspace'); } @@ -28,17 +29,21 @@ export function useCreateEnvironment() { defaultValue: 'My Environment', confirmText: 'Create', }); - if (name == null) throw new Error('No name provided to create environment'); + if (name == null) return null; return createWorkspaceModel({ model: 'environment', name, variables: [], workspaceId, - environmentId: baseEnvironment.id, + base: false, }); }, onSuccess: async (environmentId) => { + if (environmentId == null) { + return; // Was not created + } + setWorkspaceSearchParams({ environment_id: environmentId }); }, }); diff --git a/src-web/hooks/useEnvironmentVariables.ts b/src-web/hooks/useEnvironmentVariables.ts new file mode 100644 index 00000000..424176eb --- /dev/null +++ b/src-web/hooks/useEnvironmentVariables.ts @@ -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 = {}; + 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]); +} diff --git a/src-web/hooks/useEnvironmentsBreakdown.ts b/src-web/hooks/useEnvironmentsBreakdown.ts index a2df647c..dea73621 100644 --- a/src-web/hooks/useEnvironmentsBreakdown.ts +++ b/src-web/hooks/useEnvironmentsBreakdown.ts @@ -5,9 +5,12 @@ import { useMemo } from 'react'; export function useEnvironmentsBreakdown() { const allEnvironments = useAtomValue(environmentsAtom); return useMemo(() => { - const baseEnvironment = allEnvironments.find((e) => e.environmentId == null) ?? null; - const subEnvironments = - allEnvironments.filter((e) => e.environmentId === (baseEnvironment?.id ?? 'n/a')) ?? []; - return { allEnvironments, baseEnvironment, subEnvironments }; + const baseEnvironments = allEnvironments.filter((e) => e.base) ?? []; + const subEnvironments = allEnvironments.filter((e) => !e.base) ?? []; + + const baseEnvironment = baseEnvironments[0] ?? null; + const otherBaseEnvironments = + baseEnvironments.filter((e) => e.id !== baseEnvironment?.id) ?? []; + return { allEnvironments, baseEnvironment, subEnvironments, otherBaseEnvironments }; }, [allEnvironments]); } diff --git a/src-web/hooks/useGenerateThemeCss.ts b/src-web/hooks/useGenerateThemeCss.ts index 5484c018..1cd0f03b 100644 --- a/src-web/hooks/useGenerateThemeCss.ts +++ b/src-web/hooks/useGenerateThemeCss.ts @@ -1,3 +1,4 @@ +import { copyToClipboard } from '../lib/copy'; import { catppuccinMacchiato } from '../lib/theme/themes/catppuccin'; import { githubLight } from '../lib/theme/themes/github'; 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 { yaakDark } from '../lib/theme/themes/yaak'; import { getThemeCSS } from '../lib/theme/window'; -import { useCopy } from './useCopy'; import { useListenToTauriEvent } from './useListenToTauriEvent'; export function useGenerateThemeCss() { - const copy = useCopy(); useListenToTauriEvent('generate_theme_css', () => { const themesCss = [ yaakDark, @@ -23,6 +22,6 @@ export function useGenerateThemeCss() { ] .map(getThemeCSS) .join('\n\n'); - copy(themesCss); + copyToClipboard(themesCss); }); } diff --git a/src-web/hooks/usePinnedWebsocketConnection.ts b/src-web/hooks/usePinnedWebsocketConnection.ts index 76a606c8..698d9027 100644 --- a/src-web/hooks/usePinnedWebsocketConnection.ts +++ b/src-web/hooks/usePinnedWebsocketConnection.ts @@ -1,10 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import type { WebsocketConnection, WebsocketEvent } from '@yaakapp-internal/models'; -import { - replaceModelsInStore, - websocketConnectionsAtom, - websocketEventsAtom, -} from '@yaakapp-internal/models'; +import { replaceModelsInStore , websocketConnectionsAtom, websocketEventsAtom } from '@yaakapp-internal/models'; import { atom, useAtomValue } from 'jotai'; import { useEffect } from 'react'; import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage'; @@ -35,13 +31,6 @@ export const activeWebsocketConnectionAtom = atom((g return activeConnections.find((c) => c.id === pinnedConnectionId) ?? activeConnections[0] ?? null; }); -export const activeWebsocketEventsAtom = atom(async (get) => { - const connection = get(activeWebsocketConnectionAtom); - return invoke('plugin:yaak-models|websocket_events', { - connectionId: connection?.id ?? 'n/a', - }); -}); - export function setPinnedWebsocketConnectionId(id: string | null) { const activeRequestId = jotaiStore.get(activeRequestIdAtom); const activeConnections = jotaiStore.get(activeWebsocketConnectionsAtom); diff --git a/src-web/init/sync.ts b/src-web/init/sync.ts index 3e45e845..6098625c 100644 --- a/src-web/init/sync.ts +++ b/src-web/init/sync.ts @@ -68,7 +68,7 @@ function isModelRelevant(m: AnyModel) { if ( m.model !== 'workspace' && m.model !== 'folder' && - // m.model !== 'environment' && // Not synced anymore + m.model !== 'environment' && m.model !== 'http_request' && m.model !== 'grpc_request' && m.model !== 'websocket_request' diff --git a/src-web/lib/copy.ts b/src-web/lib/copy.ts new file mode 100644 index 00000000..f7bc7878 --- /dev/null +++ b/src-web/lib/copy.ts @@ -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', + }); + } +}