Compare commits

..

29 Commits

Author SHA1 Message Date
Gregory Schier
749df338c5 Disable wasm-opt 2025-05-15 14:29:29 -07:00
Gregory Schier
3184c1b79e Remove dynamic imports 2025-05-15 12:29:45 -07:00
Gregory Schier
b52bf7cd56 Fix HttpResponse AnyModel deserialization 2025-05-15 09:37:05 -07:00
Gregory Schier
d962d7f94b remove codemirror dep and restructure a bit 2025-05-15 09:28:14 -07:00
Gregory Schier
21e2a67c1e Fix sidebar scroll drag
https://feedback.yaak.app/p/endpoinst-scrollbar-not-clickable
2025-05-15 07:10:08 -07:00
Gregory Schier
c188435524 Fix obscured text overflow
https://feedback.yaak.app/p/pasting-token-auth-results-in-invisible-text
2025-05-15 07:00:25 -07:00
Gregory Schier
8a7a7ba49d Try fixing trusted-signing-cli 2025-05-14 20:43:59 -07:00
Gregory Schier
cbc40230bb Fix cursor position after variable on Linux
Closes https://feedback.yaak.app/p/editing-the-url-sometimes-freezes-the-app
2025-05-14 20:05:04 -07:00
Gregory Schier
bc4c3178c9 Add Content-Length: 0 default for post/put/patch
https://feedback.yaak.app/p/missing-content-length
2025-05-13 21:58:00 -07:00
Gregory Schier
121fe5b3ea Fix help text 2025-05-13 11:53:46 -07:00
Gregory Schier
861609ddc0 Update encryption help 2025-05-13 11:38:02 -07:00
Gregory Schier
e5070513ac Regenerate types 2025-05-13 10:45:41 -07:00
Gregory Schier
f5c3798df9 Ability to disable proxy config
Closes https://feedback.yaak.app/p/proxy-save-last-data
2025-05-13 10:35:02 -07:00
Gregory Schier
469d12fede Don't query KeyValue.id == NULL 2025-05-13 10:11:24 -07:00
Gregory Schier
417a02744b Don't select <Input/> text when focus is due to window focus
Closes https://feedback.yaak.app/p/url-input-auto-selects-all-text-when-regaining-focus-after
2025-05-12 22:19:16 -07:00
Gregory Schier
81e78ef24c Fix auth padding 2025-05-12 16:57:43 -07:00
Gregory Schier
dad9cebb9e Don't send empty ? for ws query params 2025-05-12 16:57:13 -07:00
Gregory Schier
b3ede3d6d6 Add error boundaries 2025-05-12 15:53:21 -07:00
Gregory Schier
035fe54df0 Send grpc metadata/auth with reflection requests
Closes https://feedback.yaak.app/p/send-metadata-during-grpc-reflection
2025-05-11 07:20:57 -07:00
Gregory Schier
5f8d99ba64 Build plugins 2025-05-11 06:46:51 -07:00
Gregory Schier
84b8d130dc Some small tweaks for plugins 2025-05-11 06:36:50 -07:00
Gregory Schier
94d4227bc1 Ability to sync environments to folder (#207) 2025-05-08 14:10:07 -07:00
Gregory Schier
77cdea2f9f Merge remote-tracking branch 'origin/master' 2025-05-08 08:02:32 -07:00
Gregory Schier
8b1ca4cb47 Fix copy response body reference
Closes https://feedback.yaak.app/p/copy-body-only-works-on-first-click
2025-05-08 08:02:27 -07:00
Gregory Schier
d3b8a42180 Update README.md 2025-05-07 12:00:34 -07:00
Gregory Schier
95f39c514a Update README.md 2025-05-07 11:59:01 -07:00
hexchain
7cba082eb0 Allow building and running on aarch64 Linux (#206)
Co-authored-by: Haochen Tong <haochentong@bytedance.com>
2025-05-01 08:06:13 -07:00
Billzabob
3b9b320be2 Send cookies for introspection (#204) 2025-04-30 10:13:06 -07:00
Gregory Schier
18664975a9 Padding on encrypted input 2025-04-26 07:33:32 -07:00
110 changed files with 1867 additions and 3122 deletions

View File

@@ -65,7 +65,7 @@ jobs:
- name: install dependencies (windows only)
if: matrix.platform == 'windows-latest'
run: cargo install --force trusted-signing-cli
run: cargo install --force trusted-signing-cli --version 0.5.0
- name: Install NPM Dependencies
run: |

View File

@@ -3,7 +3,7 @@
Yaak is a desktop API client for interacting with REST, GraphQL, Server Sent Events (SSE), WebSocket, and gRPC
APIs. It's built using [Tauri](https://tauri.app), Rust, and ReactJS.
![screenshot](https://github.com/user-attachments/assets/f18e963f-0b68-4ecb-b8b8-cb71aa9aec02)
![366149288-f18e963f-0b68-4ecb-b8b8-cb71aa9aec02](https://github.com/user-attachments/assets/ca83b7ad-5708-411b-8faf-e36b365841a4)
## Feature Overview

2277
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@yaakapp/api",
"version": "0.5.0",
"version": "0.5.3",
"main": "lib/index.js",
"typings": "./lib/index.d.ts",
"files": [

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
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, };

View File

@@ -3,3 +3,7 @@ export type * from './themes';
export * from './bindings/gen_models';
export * from './bindings/gen_events';
// Some extras for utility
export type { PartialImportResources } from './plugins/ImporterPlugin';

View File

@@ -34,7 +34,7 @@ export interface Context {
openUrl(
args: OpenWindowRequest & {
onNavigate?: (args: { url: string }) => void;
onClose: () => void;
onClose?: () => void;
},
): Promise<{ close: () => void }>;
};

View File

@@ -1,6 +1,6 @@
import type { Context } from './Context';
export type FilterPluginResponse = { filtered: string };
type FilterPluginResponse = { filtered: string };
export type FilterPlugin = {
name: string;

View File

@@ -1,15 +1,21 @@
import { Environment, Folder, GrpcRequest, HttpRequest, Workspace } from '../bindings/gen_models';
import type { AtLeast } from '../helpers';
import { ImportResources } from '../bindings/gen_events';
import { AtLeast } from '../helpers';
import type { Context } from './Context';
type ImportPluginResponse = null | {
resources: {
workspaces: AtLeast<Workspace, 'name' | 'id' | 'model'>[];
environments: AtLeast<Environment, 'name' | 'id' | 'model' | 'workspaceId'>[];
folders: AtLeast<Folder, 'name' | 'id' | 'model' | 'workspaceId'>[];
httpRequests: AtLeast<HttpRequest, 'name' | 'id' | 'model' | 'workspaceId'>[];
grpcRequests: AtLeast<GrpcRequest, 'name' | 'id' | 'model' | 'workspaceId'>[];
};
type RootFields = 'name' | 'id' | 'model';
type CommonFields = RootFields | 'workspaceId';
export type PartialImportResources = {
workspaces: Array<AtLeast<ImportResources['workspaces'][0], RootFields>>;
environments: Array<AtLeast<ImportResources['environments'][0], CommonFields>>;
folders: Array<AtLeast<ImportResources['folders'][0], CommonFields>>;
httpRequests: Array<AtLeast<ImportResources['httpRequests'][0], CommonFields>>;
grpcRequests: Array<AtLeast<ImportResources['grpcRequests'][0], CommonFields>>;
websocketRequests: Array<AtLeast<ImportResources['websocketRequests'][0], CommonFields>>;
};
export type ImportPluginResponse = null | {
resources: PartialImportResources;
};
export type ImporterPlugin = {

View File

@@ -9,12 +9,14 @@ const NODE_VERSION = 'v22.9.0';
// `${process.platform}_${process.arch}`
const MAC_ARM = 'darwin_arm64';
const MAC_X64 = 'darwin_x64';
const LNX_ARM = 'linux_arm64';
const LNX_X64 = 'linux_x64';
const WIN_X64 = 'win32_x64';
const URL_MAP = {
[MAC_ARM]: `https://nodejs.org/download/release/${NODE_VERSION}/node-${NODE_VERSION}-darwin-arm64.tar.gz`,
[MAC_X64]: `https://nodejs.org/download/release/${NODE_VERSION}/node-${NODE_VERSION}-darwin-x64.tar.gz`,
[LNX_ARM]: `https://nodejs.org/download/release/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz`,
[LNX_X64]: `https://nodejs.org/download/release/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz`,
[WIN_X64]: `https://nodejs.org/download/release/${NODE_VERSION}/node-${NODE_VERSION}-win-x64.zip`,
};
@@ -22,15 +24,17 @@ const URL_MAP = {
const SRC_BIN_MAP = {
[MAC_ARM]: `node-${NODE_VERSION}-darwin-arm64/bin/node`,
[MAC_X64]: `node-${NODE_VERSION}-darwin-x64/bin/node`,
[LNX_ARM]: `node-${NODE_VERSION}-linux-arm64/bin/node`,
[LNX_X64]: `node-${NODE_VERSION}-linux-x64/bin/node`,
[WIN_X64]: `node-${NODE_VERSION}-win-x64/node.exe`,
};
const DST_BIN_MAP = {
darwin_arm64: 'yaaknode-aarch64-apple-darwin',
darwin_x64: 'yaaknode-x86_64-apple-darwin',
linux_x64: 'yaaknode-x86_64-unknown-linux-gnu',
win32_x64: 'yaaknode-x86_64-pc-windows-msvc.exe',
[MAC_ARM]: 'yaaknode-aarch64-apple-darwin',
[MAC_X64]: 'yaaknode-x86_64-apple-darwin',
[LNX_ARM]: 'yaaknode-aarch64-unknown-linux-gnu',
[LNX_X64]: 'yaaknode-x86_64-unknown-linux-gnu',
[WIN_X64]: 'yaaknode-x86_64-pc-windows-msvc.exe',
};
const key = `${process.platform}_${process.env.YAAK_TARGET_ARCH ?? process.arch}`;

View File

@@ -9,12 +9,14 @@ const VERSION = '28.3';
// `${process.platform}_${process.arch}`
const MAC_ARM = 'darwin_arm64';
const MAC_X64 = 'darwin_x64';
const LNX_ARM = 'linux_arm64';
const LNX_X64 = 'linux_x64';
const WIN_X64 = 'win32_x64';
const URL_MAP = {
[MAC_ARM]: `https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/protoc-${VERSION}-osx-aarch_64.zip`,
[MAC_X64]: `https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/protoc-${VERSION}-osx-x86_64.zip`,
[LNX_ARM]: `https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/protoc-${VERSION}-linux-aarch_64.zip`,
[LNX_X64]: `https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/protoc-${VERSION}-linux-x86_64.zip`,
[WIN_X64]: `https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/protoc-${VERSION}-win64.zip`,
};
@@ -22,6 +24,7 @@ const URL_MAP = {
const SRC_BIN_MAP = {
[MAC_ARM]: 'bin/protoc',
[MAC_X64]: 'bin/protoc',
[LNX_ARM]: 'bin/protoc',
[LNX_X64]: 'bin/protoc',
[WIN_X64]: 'bin/protoc.exe',
};
@@ -29,6 +32,7 @@ const SRC_BIN_MAP = {
const DST_BIN_MAP = {
[MAC_ARM]: 'yaakprotoc-aarch64-apple-darwin',
[MAC_X64]: 'yaakprotoc-x86_64-apple-darwin',
[LNX_ARM]: 'yaakprotoc-aarch64-unknown-linux-gnu',
[LNX_X64]: 'yaakprotoc-x86_64-unknown-linux-gnu',
[WIN_X64]: 'yaakprotoc-x86_64-pc-windows-msvc.exe',
};

View File

@@ -0,0 +1,11 @@
-- There used to be sync code that skipped over environments because we didn't
-- want to sync potentially insecure data. With encryption, it is now possible
-- to sync environments securely. However, there were already sync states in the
-- DB that marked environments as "Synced". Running the sync code on these envs
-- would mark them as deleted by FS (exist in SyncState but not on FS).
--
-- To undo this mess, we have this migration to delete all environment-related
-- sync states so we can sync from a clean slate.
DELETE
FROM sync_states
WHERE model_id LIKE 'ev_%';

View File

@@ -0,0 +1,20 @@
-- Add a public column to represent whether an environment can be shared or exported
ALTER TABLE environments
ADD COLUMN public BOOLEAN DEFAULT FALSE;
-- Add a base column to represent whether an environment is a base or sub environment. We used to
-- do this with environment_id, but we need a more flexible solution now that envs can be optionally
-- synced. E.g., it's now possible to only import a sub environment from a different client without
-- its base environment "parent."
ALTER TABLE environments
ADD COLUMN base BOOLEAN DEFAULT FALSE;
-- SQLite doesn't support dynamic default values, so we update `base` based on the value of
-- environment_id.
UPDATE environments
SET base = TRUE
WHERE environment_id IS NULL;
-- Finally, we drop the old `environment_id` column that will no longer be used
ALTER TABLE environments
DROP COLUMN environment_id;

View File

@@ -1,10 +1,14 @@
use std::collections::BTreeMap;
use crate::error::Result;
use KeyAndValueRef::{Ascii, Binary};
use tauri::{Manager, Runtime, WebviewWindow};
use yaak_grpc::{KeyAndValueRef, MetadataMap};
use yaak_models::models::GrpcRequest;
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader};
use yaak_plugins::manager::PluginManager;
pub fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String> {
pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String> {
let mut entries = BTreeMap::new();
for r in metadata.iter() {
match r {
@@ -14,3 +18,48 @@ pub fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String> {
}
entries
}
pub(crate) async fn build_metadata<R: Runtime>(
window: &WebviewWindow<R>,
request: &GrpcRequest,
) -> Result<BTreeMap<String, String>> {
let plugin_manager = window.state::<PluginManager>();
let mut metadata = BTreeMap::new();
// Add the rest of metadata
for h in request.clone().metadata {
if h.name.is_empty() && h.value.is_empty() {
continue;
}
if !h.enabled {
continue;
}
metadata.insert(h.name, h.value);
}
if let Some(auth_name) = request.authentication_type.clone() {
let auth = request.authentication.clone();
let plugin_req = CallHttpAuthenticationRequest {
context_id: format!("{:x}", md5::compute(request.id.clone())),
values: serde_json::from_value(serde_json::to_value(&auth).unwrap()).unwrap(),
method: "POST".to_string(),
url: request.url.clone(),
headers: metadata
.iter()
.map(|(name, value)| HttpHeader {
name: name.to_string(),
value: value.to_string(),
})
.collect(),
};
let plugin_result =
plugin_manager.call_http_authentication(&window, &auth_name, plugin_req).await?;
for header in plugin_result.set_headers {
metadata.insert(header.name, header.value);
}
}
Ok(metadata)
}

View File

@@ -123,7 +123,12 @@ pub async fn send_http_request<R: Runtime>(
match settings.proxy {
Some(ProxySetting::Disabled) => client_builder = client_builder.no_proxy(),
Some(ProxySetting::Enabled { http, https, auth }) => {
Some(ProxySetting::Enabled {
http,
https,
auth,
disabled,
}) if !disabled => {
debug!("Using proxy http={http} https={https}");
let mut proxy = Proxy::custom(move |url| {
let http = if http.is_empty() { None } else { Some(http.to_owned()) };
@@ -143,7 +148,7 @@ pub async fn send_http_request<R: Runtime>(
client_builder = client_builder.proxy(proxy);
}
None => {} // Nothing to do for this one, as it is the default
_ => {} // Nothing to do for this one, as it is the default
}
// Add cookie store if specified
@@ -394,6 +399,15 @@ pub async fn send_http_request<R: Runtime>(
} else {
warn!("Unsupported body type: {}", body_type);
}
} else {
// No body set
let method = request.method.to_ascii_lowercase();
let is_body_method = method == "post" || method == "put" || method == "patch";
// Add Content-Length for methods that commonly accept a body because some servers
// will error if they don't receive it.
if is_body_method && !headers.contains_key("content-length") {
headers.insert("Content-Length", HeaderValue::from_static("0"));
}
}
// Add headers last, because previous steps may modify them

View File

@@ -1,7 +1,7 @@
extern crate core;
use crate::encoding::read_response_body;
use crate::error::Error::GenericError;
use crate::grpc::metadata_to_map;
use crate::grpc::{build_metadata, metadata_to_map};
use crate::http_request::send_http_request;
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_template};
@@ -38,9 +38,9 @@ use yaak_models::util::{
BatchUpsertResult, UpdateSource, get_workspace_export_resources, maybe_gen_id, maybe_gen_id_opt,
};
use yaak_plugins::events::{
BootResponse, CallHttpAuthenticationRequest, CallHttpRequestActionRequest, FilterResponse,
BootResponse, CallHttpRequestActionRequest, FilterResponse,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionsResponse, HttpHeader, InternalEvent,
GetHttpRequestActionsResponse, GetTemplateFunctionsResponse, InternalEvent,
InternalEventPayload, JsonPrimitive, PluginWindowContext, RenderPurpose,
};
use yaak_plugins::manager::PluginManager;
@@ -166,6 +166,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
.await?;
let uri = safe_uri(&req.url);
let metadata = build_metadata(&window, &req).await?;
Ok(grpc_handle
.lock()
@@ -174,6 +175,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
&req.id,
&uri,
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
&metadata,
)
.await
.map_err(|e| GenericError(e.to_string()))?)
@@ -186,7 +188,6 @@ async fn cmd_grpc_go<R: Runtime>(
proto_files: Vec<String>,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> YaakResult<String> {
let environment = match environment_id {
@@ -208,42 +209,7 @@ async fn cmd_grpc_go<R: Runtime>(
)
.await?;
let mut metadata = BTreeMap::new();
// Add the rest of metadata
for h in request.clone().metadata {
if h.name.is_empty() && h.value.is_empty() {
continue;
}
if !h.enabled {
continue;
}
metadata.insert(h.name, h.value);
}
if let Some(auth_name) = request.authentication_type.clone() {
let auth = request.authentication.clone();
let plugin_req = CallHttpAuthenticationRequest {
context_id: format!("{:x}", md5::compute(request_id.to_string())),
values: serde_json::from_value(serde_json::to_value(&auth).unwrap()).unwrap(),
method: "POST".to_string(),
url: request.url.clone(),
headers: metadata
.iter()
.map(|(name, value)| HttpHeader {
name: name.to_string(),
value: value.to_string(),
})
.collect(),
};
let plugin_result =
plugin_manager.call_http_authentication(&window, &auth_name, plugin_req).await?;
for header in plugin_result.set_headers {
metadata.insert(header.name, header.value);
}
}
let metadata = build_metadata(&window, &request).await?;
let conn = app_handle.db().upsert_grpc_connection(
&GrpcConnection {
@@ -291,6 +257,7 @@ async fn cmd_grpc_go<R: Runtime>(
&request.clone().id,
uri.as_str(),
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
&metadata,
)
.await;
@@ -448,7 +415,7 @@ async fn cmd_grpc_go<R: Runtime>(
match (method_desc.is_client_streaming(), method_desc.is_server_streaming()) {
(true, true) => (
Some(
connection.streaming(&service, &method, in_msg_stream, metadata).await,
connection.streaming(&service, &method, in_msg_stream, &metadata).await,
),
None,
),
@@ -456,16 +423,16 @@ async fn cmd_grpc_go<R: Runtime>(
None,
Some(
connection
.client_streaming(&service, &method, in_msg_stream, metadata)
.client_streaming(&service, &method, in_msg_stream, &metadata)
.await,
),
),
(false, true) => (
Some(connection.server_streaming(&service, &method, &msg, metadata).await),
Some(connection.server_streaming(&service, &method, &msg, &metadata).await),
None,
),
(false, false) => {
(None, Some(connection.unary(&service, &method, &msg, metadata).await))
(None, Some(connection.unary(&service, &method, &msg, &metadata).await))
}
};
@@ -831,7 +798,6 @@ async fn cmd_import_data<R: Runtime>(
.map(|mut v| {
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.environment_id = maybe_gen_id_opt::<Environment>(v.environment_id, &mut id_map);
v
})
.collect();
@@ -985,10 +951,10 @@ async fn cmd_export_data<R: Runtime>(
app_handle: AppHandle<R>,
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)

View File

@@ -7296,35 +7296,48 @@ __export(src_exports, {
});
module.exports = __toCommonJS(src_exports);
var import_yaml = __toESM(require_dist());
var plugin = {
importer: {
name: "Insomnia",
description: "Import Insomnia workspaces",
onImport(_ctx, args) {
return convertInsomnia(args.text);
}
// src/common.ts
function convertSyntax(variable) {
if (!isJSString(variable)) return variable;
return variable.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}");
}
function isJSObject(obj) {
return Object.prototype.toString.call(obj) === "[object Object]";
}
function isJSString(obj) {
return Object.prototype.toString.call(obj) === "[object String]";
}
function convertId(id) {
if (id.startsWith("GENERATE_ID::")) {
return id;
}
};
function convertInsomnia(contents) {
let parsed;
try {
parsed = JSON.parse(contents);
} catch (e) {
return `GENERATE_ID::${id}`;
}
function deleteUndefinedAttrs(obj) {
if (Array.isArray(obj) && obj != null) {
return obj.map(deleteUndefinedAttrs);
} else if (typeof obj === "object" && obj != null) {
return Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== void 0).map(([k, v]) => [k, deleteUndefinedAttrs(v)])
);
} else {
return obj;
}
try {
parsed = parsed ?? import_yaml.default.parse(contents);
} catch (e) {
}
if (!isJSObject(parsed)) return;
if (!Array.isArray(parsed.resources)) return;
}
// src/v4.ts
function convertInsomniaV4(parsed) {
if (!Array.isArray(parsed.resources)) return null;
const resources = {
workspaces: [],
httpRequests: [],
grpcRequests: [],
environments: [],
folders: []
folders: [],
grpcRequests: [],
httpRequests: [],
websocketRequests: [],
workspaces: []
};
const workspacesToImport = parsed.resources.filter(isWorkspace);
const workspacesToImport = parsed.resources.filter((r) => isJSObject(r) && r._type === "workspace");
for (const w of workspacesToImport) {
resources.workspaces.push({
id: convertId(w._id),
@@ -7335,25 +7348,25 @@ function convertInsomnia(contents) {
description: w.description || void 0
});
const environmentsToImport = parsed.resources.filter(
(r) => isEnvironment(r)
(r) => isJSObject(r) && r._type === "environment"
);
resources.environments.push(
...environmentsToImport.map((r) => importEnvironment(r, w._id))
);
const nextFolder = (parentId) => {
const children = parsed.resources.filter((r) => r.parentId === parentId);
let sortPriority = 0;
for (const child of children) {
if (isRequestGroup(child)) {
if (!isJSObject(child)) continue;
if (child._type === "request_group") {
resources.folders.push(importFolder(child, w._id));
nextFolder(child._id);
} else if (isHttpRequest(child)) {
} else if (child._type === "request") {
resources.httpRequests.push(
importHttpRequest(child, w._id, sortPriority++)
importHttpRequest(child, w._id)
);
} else if (isGrpcRequest(child)) {
} else if (child._type === "grpc_request") {
resources.grpcRequests.push(
importGrpcRequest(child, w._id, sortPriority++)
importGrpcRequest(child, w._id)
);
}
}
@@ -7364,62 +7377,9 @@ function convertInsomnia(contents) {
resources.grpcRequests = resources.grpcRequests.filter(Boolean);
resources.environments = resources.environments.filter(Boolean);
resources.workspaces = resources.workspaces.filter(Boolean);
return { resources: deleteUndefinedAttrs(resources) };
return { resources };
}
function importEnvironment(e, workspaceId) {
return {
id: convertId(e._id),
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),
model: "environment",
name: e.name,
variables: Object.entries(e.data).map(([name, value]) => ({
enabled: true,
name,
value: `${value}`
}))
};
}
function importFolder(f, workspaceId) {
return {
id: convertId(f._id),
createdAt: f.created ? new Date(f.created).toISOString().replace("Z", "") : void 0,
updatedAt: f.updated ? new Date(f.updated).toISOString().replace("Z", "") : void 0,
folderId: f.parentId === workspaceId ? null : convertId(f.parentId),
workspaceId: convertId(workspaceId),
description: f.description || void 0,
model: "folder",
name: f.name
};
}
function importGrpcRequest(r, workspaceId, sortPriority = 0) {
const parts = r.protoMethodName.split("/").filter((p) => p !== "");
const service = parts[0] ?? null;
const method = parts[1] ?? null;
return {
id: convertId(r._id),
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : void 0,
updatedAt: r.updated ? new Date(r.updated).toISOString().replace("Z", "") : void 0,
workspaceId: convertId(workspaceId),
folderId: r.parentId === workspaceId ? null : convertId(r.parentId),
model: "grpc_request",
sortPriority,
name: r.name,
description: r.description || void 0,
url: convertSyntax(r.url),
service,
method,
message: r.body?.text ?? "",
metadata: (r.metadata ?? []).map((h) => ({
enabled: !h.disabled,
name: h.name ?? "",
value: h.value ?? ""
})).filter(({ name, value }) => name !== "" || value !== "")
};
}
function importHttpRequest(r, workspaceId, sortPriority = 0) {
function importHttpRequest(r, workspaceId) {
let bodyType = null;
let body = {};
if (r.body.mimeType === "application/octet-stream") {
@@ -7466,13 +7426,13 @@ function importHttpRequest(r, workspaceId, sortPriority = 0) {
};
}
return {
id: convertId(r._id),
id: convertId(r.meta?.id ?? r._id),
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : void 0,
updatedAt: r.updated ? new Date(r.updated).toISOString().replace("Z", "") : void 0,
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : void 0,
workspaceId: convertId(workspaceId),
folderId: r.parentId === workspaceId ? null : convertId(r.parentId),
model: "http_request",
sortPriority,
sortPriority: r.metaSortKey,
name: r.name,
description: r.description || void 0,
url: convertSyntax(r.url),
@@ -7488,47 +7448,309 @@ function importHttpRequest(r, workspaceId, sortPriority = 0) {
})).filter(({ name, value }) => name !== "" || value !== "")
};
}
function convertSyntax(variable) {
if (!isJSString(variable)) return variable;
return variable.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}");
function importGrpcRequest(r, workspaceId) {
const parts = r.protoMethodName.split("/").filter((p) => p !== "");
const service = parts[0] ?? null;
const method = parts[1] ?? null;
return {
id: convertId(r.meta?.id ?? r._id),
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : void 0,
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : void 0,
workspaceId: convertId(workspaceId),
folderId: r.parentId === workspaceId ? null : convertId(r.parentId),
model: "grpc_request",
sortPriority: r.metaSortKey,
name: r.name,
description: r.description || void 0,
url: convertSyntax(r.url),
service,
method,
message: r.body?.text ?? "",
metadata: (r.metadata ?? []).map((h) => ({
enabled: !h.disabled,
name: h.name ?? "",
value: h.value ?? ""
})).filter(({ name, value }) => name !== "" || value !== "")
};
}
function isWorkspace(obj) {
return isJSObject(obj) && obj._type === "workspace";
function importFolder(f, workspaceId) {
return {
id: convertId(f._id),
createdAt: f.created ? new Date(f.created).toISOString().replace("Z", "") : void 0,
updatedAt: f.modified ? new Date(f.modified).toISOString().replace("Z", "") : void 0,
folderId: f.parentId === workspaceId ? null : convertId(f.parentId),
workspaceId: convertId(workspaceId),
description: f.description || void 0,
model: "folder",
name: f.name
};
}
function isRequestGroup(obj) {
return isJSObject(obj) && obj._type === "request_group";
function importEnvironment(e, workspaceId, isParent) {
return {
id: convertId(e._id),
createdAt: e.created ? new Date(e.created).toISOString().replace("Z", "") : void 0,
updatedAt: e.modified ? new Date(e.modified).toISOString().replace("Z", "") : void 0,
workspaceId: convertId(workspaceId),
// @ts-ignore
sortPriority: e.metaSortKey,
// Will be added to Yaak later
base: isParent ?? e.parentId === workspaceId,
model: "environment",
name: e.name,
variables: Object.entries(e.data).map(([name, value]) => ({
enabled: true,
name,
value: `${value}`
}))
};
}
function isHttpRequest(obj) {
return isJSObject(obj) && obj._type === "request";
// src/v5.ts
function convertInsomniaV5(parsed) {
if (!Array.isArray(parsed.collection)) return null;
const resources = {
environments: [],
folders: [],
grpcRequests: [],
httpRequests: [],
websocketRequests: [],
workspaces: []
};
const meta = parsed.meta ?? {};
resources.workspaces.push({
id: convertId(meta.id ?? "collection"),
createdAt: meta.created ? new Date(meta.created).toISOString().replace("Z", "") : void 0,
updatedAt: meta.modified ? new Date(meta.modified).toISOString().replace("Z", "") : void 0,
model: "workspace",
name: parsed.name,
description: meta.description || void 0
});
resources.environments.push(
importEnvironment2(parsed.environments, meta.id, true),
...(parsed.environments.subEnvironments ?? []).map((r) => importEnvironment2(r, meta.id))
);
const nextFolder = (children, parentId) => {
for (const child of children ?? []) {
if (!isJSObject(child)) continue;
if (Array.isArray(child.children)) {
resources.folders.push(importFolder2(child, meta.id, parentId));
nextFolder(child.children, child.meta.id);
} else if (child.method) {
resources.httpRequests.push(
importHttpRequest2(child, meta.id, parentId)
);
} else if (child.protoFileId) {
resources.grpcRequests.push(
importGrpcRequest2(child, meta.id, parentId)
);
} else if (child.url) {
resources.websocketRequests.push(
importWebsocketRequest(child, meta.id, parentId)
);
}
}
};
nextFolder(parsed.collection ?? [], meta.id);
resources.httpRequests = resources.httpRequests.filter(Boolean);
resources.grpcRequests = resources.grpcRequests.filter(Boolean);
resources.environments = resources.environments.filter(Boolean);
resources.workspaces = resources.workspaces.filter(Boolean);
return { resources };
}
function isGrpcRequest(obj) {
return isJSObject(obj) && obj._type === "grpc_request";
}
function isEnvironment(obj) {
return isJSObject(obj) && obj._type === "environment";
}
function isJSObject(obj) {
return Object.prototype.toString.call(obj) === "[object Object]";
}
function isJSString(obj) {
return Object.prototype.toString.call(obj) === "[object String]";
}
function convertId(id) {
if (id.startsWith("GENERATE_ID::")) {
return id;
function importHttpRequest2(r, workspaceId, parentId) {
const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created;
const updated = r.meta?.modified ?? r.updated;
const sortKey = r.meta?.sortKey ?? r.sortKey;
let bodyType = null;
let body = {};
if (r.body.mimeType === "application/octet-stream") {
bodyType = "binary";
body = { filePath: r.body.fileName ?? "" };
} else if (r.body?.mimeType === "application/x-www-form-urlencoded") {
bodyType = "application/x-www-form-urlencoded";
body = {
form: (r.body.params ?? []).map((p) => ({
enabled: !p.disabled,
name: p.name ?? "",
value: p.value ?? ""
}))
};
} else if (r.body?.mimeType === "multipart/form-data") {
bodyType = "multipart/form-data";
body = {
form: (r.body.params ?? []).map((p) => ({
enabled: !p.disabled,
name: p.name ?? "",
value: p.value ?? "",
file: p.fileName ?? null
}))
};
} else if (r.body?.mimeType === "application/graphql") {
bodyType = "graphql";
body = { text: convertSyntax(r.body.text ?? "") };
} else if (r.body?.mimeType === "application/json") {
bodyType = "application/json";
body = { text: convertSyntax(r.body.text ?? "") };
}
return `GENERATE_ID::${id}`;
return {
id: convertId(id),
workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : void 0,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : void 0,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
model: "http_request",
name: r.name,
description: r.meta?.description || void 0,
url: convertSyntax(r.url),
body,
bodyType,
method: r.method,
...importHeaders(r),
...importAuthentication(r)
};
}
function deleteUndefinedAttrs(obj) {
if (Array.isArray(obj) && obj != null) {
return obj.map(deleteUndefinedAttrs);
} else if (typeof obj === "object" && obj != null) {
return Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== void 0).map(([k, v]) => [k, deleteUndefinedAttrs(v)])
);
} else {
return obj;
function importGrpcRequest2(r, workspaceId, parentId) {
const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created;
const updated = r.meta?.modified ?? r.updated;
const sortKey = r.meta?.sortKey ?? r.sortKey;
const parts = r.protoMethodName.split("/").filter((p) => p !== "");
const service = parts[0] ?? null;
const method = parts[1] ?? null;
return {
model: "grpc_request",
id: convertId(id),
workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : void 0,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : void 0,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
name: r.name,
description: r.description || void 0,
url: convertSyntax(r.url),
service,
method,
message: r.body?.text ?? "",
metadata: (r.metadata ?? []).map((h) => ({
enabled: !h.disabled,
name: h.name ?? "",
value: h.value ?? ""
})).filter(({ name, value }) => name !== "" || value !== "")
};
}
function importWebsocketRequest(r, workspaceId, parentId) {
const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created;
const updated = r.meta?.modified ?? r.updated;
const sortKey = r.meta?.sortKey ?? r.sortKey;
return {
model: "websocket_request",
id: convertId(id),
workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : void 0,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : void 0,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
name: r.name,
description: r.description || void 0,
url: convertSyntax(r.url),
message: r.body?.text ?? "",
...importHeaders(r),
...importAuthentication(r)
};
}
function importHeaders(r) {
const headers = (r.headers ?? []).map((h) => ({
enabled: !h.disabled,
name: h.name ?? "",
value: h.value ?? ""
})).filter(({ name, value }) => name !== "" || value !== "");
return { headers };
}
function importAuthentication(r) {
let authenticationType = null;
let authentication = {};
if (r.authentication?.type === "bearer") {
authenticationType = "bearer";
authentication = {
token: convertSyntax(r.authentication.token)
};
} else if (r.authentication?.type === "basic") {
authenticationType = "basic";
authentication = {
username: convertSyntax(r.authentication.username),
password: convertSyntax(r.authentication.password)
};
}
return { authenticationType, authentication };
}
function importFolder2(f, workspaceId, parentId) {
const id = f.meta?.id ?? f._id;
const created = f.meta?.created ?? f.created;
const updated = f.meta?.modified ?? f.updated;
const sortKey = f.meta?.sortKey ?? f.sortKey;
return {
model: "folder",
id: convertId(id),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : void 0,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : void 0,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
workspaceId: convertId(workspaceId),
description: f.description || void 0,
name: f.name
};
}
function importEnvironment2(e, workspaceId, isParent) {
const id = e.meta?.id ?? e._id;
const created = e.meta?.created ?? e.created;
const updated = e.meta?.modified ?? e.updated;
const sortKey = e.meta?.sortKey ?? e.sortKey;
return {
id: convertId(id),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : void 0,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : void 0,
workspaceId: convertId(workspaceId),
public: !e.isPrivate,
// @ts-ignore
sortPriority: sortKey,
// Will be added to Yaak later
base: isParent ?? e.parentId === workspaceId,
model: "environment",
name: e.name,
variables: Object.entries(e.data).map(([name, value]) => ({
enabled: true,
name,
value: `${value}`
}))
};
}
// src/index.ts
var plugin = {
importer: {
name: "Insomnia",
description: "Import Insomnia workspaces",
async onImport(_ctx, args) {
return convertInsomnia(args.text);
}
}
};
function convertInsomnia(contents) {
let parsed;
try {
parsed = JSON.parse(contents);
} catch (e) {
}
try {
parsed = parsed ?? import_yaml.default.parse(contents);
} catch (e) {
}
if (!isJSObject(parsed)) return null;
const result = convertInsomniaV5(parsed) ?? convertInsomniaV4(parsed);
return deleteUndefinedAttrs(result);
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {

View File

@@ -69,6 +69,12 @@ function migrateImport(contents) {
}
}
}
for (const environment of parsed.resources.environments ?? []) {
if ("environmentId" in environment) {
environment.base = environment.environmentId == null;
delete environment.environmentId;
}
}
return { resources: parsed.resources };
}
function isJSObject(obj) {

View File

@@ -38,7 +38,7 @@ pub(crate) fn decrypt_data(cipher_data: &[u8], key: &Key<XChaCha20Poly1305>) ->
let (nonce, ciphered_data) = rest.split_at_checked(nonce_bytes).ok_or(InvalidEncryptedData)?;
let 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)]

View File

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

View File

@@ -1,4 +1,6 @@
use crate::error::Error::{GenericError, IncorrectWorkspaceKey, MissingWorkspaceKey};
use crate::error::Error::{
GenericError, IncorrectWorkspaceKey, MissingWorkspaceKey, WorkspaceKeyDecryptionError,
};
use crate::error::{Error, Result};
use crate::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());

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
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, };

View File

@@ -1,13 +1,15 @@
use crate::manager::decorate_req;
use crate::transport::get_transport;
use async_recursion::async_recursion;
use hyper_rustls::HttpsConnector;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::client::legacy::Client;
use hyper_util::client::legacy::connect::HttpConnector;
use log::debug;
use std::collections::BTreeMap;
use tokio_stream::StreamExt;
use tonic::Request;
use tonic::body::BoxBody;
use tonic::transport::Uri;
use tonic::Request;
use tonic_reflection::pb::v1::server_reflection_request::MessageRequest;
use tonic_reflection::pb::v1::server_reflection_response::MessageResponse;
use tonic_reflection::pb::v1::{
@@ -44,6 +46,7 @@ impl AutoReflectionClient {
pub async fn send_reflection_request(
&mut self,
message: MessageRequest,
metadata: &BTreeMap<String, String>,
) -> Result<MessageResponse, String> {
let reflection_request = ServerReflectionRequest {
host: "".into(), // Doesn't matter
@@ -51,7 +54,9 @@ impl AutoReflectionClient {
};
if self.use_v1alpha {
let request = Request::new(tokio_stream::once(to_v1alpha_request(reflection_request)));
let mut request = Request::new(tokio_stream::once(to_v1alpha_request(reflection_request)));
decorate_req(metadata, &mut request).map_err(|e| e.to_string())?;
self.client_v1alpha
.server_reflection_info(request)
.await
@@ -70,7 +75,9 @@ impl AutoReflectionClient {
.ok_or("No reflection response".to_string())
.map(|resp| to_v1_msg_response(resp))
} else {
let request = Request::new(tokio_stream::once(reflection_request));
let mut request = Request::new(tokio_stream::once(reflection_request));
decorate_req(metadata, &mut request).map_err(|e| e.to_string())?;
let resp = self.client_v1.server_reflection_info(request).await;
match resp {
Ok(r) => Ok(r),
@@ -79,7 +86,7 @@ impl AutoReflectionClient {
// If v1 fails, change to v1alpha and try again
debug!("gRPC schema reflection falling back to v1alpha");
self.use_v1alpha = true;
return self.send_reflection_request(message).await;
return self.send_reflection_request(message, metadata).await;
}
_ => Err(e),
},

View File

@@ -69,7 +69,7 @@ impl GrpcConnection {
service: &str,
method: &str,
message: &str,
metadata: BTreeMap<String, String>,
metadata: &BTreeMap<String, String>,
) -> Result<Response<DynamicMessage>, StreamError> {
let method = &self.method(&service, &method)?;
let input_message = method.input();
@@ -96,7 +96,7 @@ impl GrpcConnection {
service: &str,
method: &str,
stream: ReceiverStream<DynamicMessage>,
metadata: BTreeMap<String, String>,
metadata: &BTreeMap<String, String>,
) -> Result<Response<Streaming<DynamicMessage>>, StreamError> {
let method = &self.method(&service, &method)?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
@@ -116,7 +116,7 @@ impl GrpcConnection {
service: &str,
method: &str,
stream: ReceiverStream<DynamicMessage>,
metadata: BTreeMap<String, String>,
metadata: &BTreeMap<String, String>,
) -> Result<Response<DynamicMessage>, StreamError> {
let method = &self.method(&service, &method)?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
@@ -137,7 +137,7 @@ impl GrpcConnection {
service: &str,
method: &str,
message: &str,
metadata: BTreeMap<String, String>,
metadata: &BTreeMap<String, String>,
) -> Result<Response<Streaming<DynamicMessage>>, StreamError> {
let method = &self.method(&service, &method)?;
let input_message = method.input();
@@ -180,10 +180,11 @@ impl GrpcHandle {
id: &str,
uri: &str,
proto_files: &Vec<PathBuf>,
metadata: &BTreeMap<String, String>,
) -> Result<(), String> {
let pool = if proto_files.is_empty() {
let full_uri = uri_from_str(uri)?;
fill_pool_from_reflection(&full_uri).await
fill_pool_from_reflection(&full_uri, metadata).await
} else {
fill_pool_from_files(&self.app_handle, proto_files).await
}?;
@@ -197,9 +198,10 @@ impl GrpcHandle {
id: &str,
uri: &str,
proto_files: &Vec<PathBuf>,
metadata: &BTreeMap<String, String>,
) -> Result<Vec<ServiceDefinition>, String> {
// Ensure reflection is up-to-date
self.reflect(id, uri, proto_files).await?;
self.reflect(id, uri, proto_files, metadata).await?;
let pool = self.get_pool(id, uri, proto_files).ok_or("Failed to get pool".to_string())?;
Ok(self.services_from_pool(&pool))
@@ -235,8 +237,9 @@ impl GrpcHandle {
id: &str,
uri: &str,
proto_files: &Vec<PathBuf>,
metadata: &BTreeMap<String, String>,
) -> Result<GrpcConnection, String> {
self.reflect(id, uri, proto_files).await?;
self.reflect(id, uri, proto_files, metadata).await?;
let pool = self.get_pool(id, uri, proto_files).ok_or("Failed to get pool")?;
let uri = uri_from_str(uri)?;
@@ -254,7 +257,10 @@ impl GrpcHandle {
}
}
fn decorate_req<T>(metadata: BTreeMap<String, String>, req: &mut Request<T>) -> Result<(), String> {
pub(crate) fn decorate_req<T>(
metadata: &BTreeMap<String, String>,
req: &mut Request<T>,
) -> Result<(), String> {
for (k, v) in metadata {
req.metadata_mut().insert(
MetadataKey::from_str(k.as_str()).map_err(|e| e.to_string())?,

View File

@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::env::temp_dir;
use std::ops::Deref;
use std::path::PathBuf;
@@ -89,11 +90,14 @@ pub async fn fill_pool_from_files(
Ok(pool)
}
pub async fn fill_pool_from_reflection(uri: &Uri) -> Result<DescriptorPool, String> {
pub async fn fill_pool_from_reflection(
uri: &Uri,
metadata: &BTreeMap<String, String>,
) -> Result<DescriptorPool, String> {
let mut pool = DescriptorPool::new();
let mut client = AutoReflectionClient::new(uri);
for service in list_services(&mut client).await? {
for service in list_services(&mut client, metadata).await? {
if service == "grpc.reflection.v1alpha.ServerReflection" {
continue;
}
@@ -101,14 +105,18 @@ pub async fn fill_pool_from_reflection(uri: &Uri) -> Result<DescriptorPool, Stri
// TODO: update reflection client to use v1
continue;
}
file_descriptor_set_from_service_name(&service, &mut pool, &mut client).await;
file_descriptor_set_from_service_name(&service, &mut pool, &mut client, metadata).await;
}
Ok(pool)
}
async fn list_services(client: &mut AutoReflectionClient) -> Result<Vec<String>, String> {
let response = client.send_reflection_request(MessageRequest::ListServices("".into())).await?;
async fn list_services(
client: &mut AutoReflectionClient,
metadata: &BTreeMap<String, String>,
) -> Result<Vec<String>, String> {
let response =
client.send_reflection_request(MessageRequest::ListServices("".into()), metadata).await?;
let list_services_response = match response {
MessageResponse::ListServicesResponse(resp) => resp,
@@ -122,9 +130,13 @@ async fn file_descriptor_set_from_service_name(
service_name: &str,
pool: &mut DescriptorPool,
client: &mut AutoReflectionClient,
metadata: &BTreeMap<String, String>,
) {
let response = match client
.send_reflection_request(MessageRequest::FileContainingSymbol(service_name.into()))
.send_reflection_request(
MessageRequest::FileContainingSymbol(service_name.into()),
metadata,
)
.await
{
Ok(resp) => resp,
@@ -139,8 +151,13 @@ async fn file_descriptor_set_from_service_name(
_ => panic!("Expected a FileDescriptorResponse variant"),
};
add_file_descriptors_to_pool(file_descriptor_response.file_descriptor_proto, pool, client)
.await;
add_file_descriptors_to_pool(
file_descriptor_response.file_descriptor_proto,
pool,
client,
metadata,
)
.await;
}
#[async_recursion]
@@ -148,6 +165,7 @@ async fn add_file_descriptors_to_pool(
fds: Vec<Vec<u8>>,
pool: &mut DescriptorPool,
client: &mut AutoReflectionClient,
metadata: &BTreeMap<String, String>,
) {
let mut topo_sort = topology::SimpleTopoSort::new();
let mut fd_mapping = std::collections::HashMap::with_capacity(fds.len());
@@ -165,7 +183,7 @@ async fn add_file_descriptors_to_pool(
if let Some(fdp) = fd_mapping.remove(&node) {
pool.add_file_descriptor_proto(fdp).expect("add file descriptor proto");
} else {
file_descriptor_set_by_filename(node.as_str(), pool, client).await;
file_descriptor_set_by_filename(node.as_str(), pool, client, metadata).await;
}
}
Err(_) => panic!("proto file got cycle!"),
@@ -177,6 +195,7 @@ async fn file_descriptor_set_by_filename(
filename: &str,
pool: &mut DescriptorPool,
client: &mut AutoReflectionClient,
metadata: &BTreeMap<String, String>,
) {
// We already fetched this file
if let Some(_) = pool.get_file_by_name(filename) {
@@ -184,7 +203,7 @@ async fn file_descriptor_set_by_filename(
}
let msg = MessageRequest::FileByFilename(filename.into());
let response = client.send_reflection_request(msg).await;
let response = client.send_reflection_request(msg, metadata).await;
let file_descriptor_response = match response {
Ok(MessageResponse::FileDescriptorResponse(resp)) => resp,
Ok(_) => {
@@ -196,8 +215,13 @@ async fn file_descriptor_set_by_filename(
}
};
add_file_descriptors_to_pool(file_descriptor_response.file_descriptor_proto, pool, client)
.await;
add_file_descriptors_to_pool(
file_descriptor_response.file_descriptor_proto,
pool,
client,
metadata,
)
.await;
}
pub fn method_desc_to_path(md: &MethodDescriptor) -> PathAndQuery {

View File

@@ -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<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, };
@@ -54,7 +54,7 @@ export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt
export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, };
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, } | { "type": "disabled" };
export type ProxySetting = { "type": "enabled", disabled: boolean, http: string, https: string, auth: ProxySettingAuth | null, } | { "type": "disabled" };
export type ProxySettingAuth = { user: string, password: string, };

View File

@@ -1,9 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment } from "./gen_models.js";
import type { Folder } from "./gen_models.js";
import type { GrpcRequest } from "./gen_models.js";
import type { HttpRequest } from "./gen_models.js";
import type { WebsocketRequest } from "./gen_models.js";
import type { Workspace } from "./gen_models.js";
import type { Environment } from "./gen_models";
import type { Folder } from "./gen_models";
import type { GrpcRequest } from "./gen_models";
import type { HttpRequest } from "./gen_models";
import type { WebsocketRequest } from "./gen_models";
import type { Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };

View File

@@ -53,9 +53,10 @@ let _activeWorkspaceId: string | null = null;
export async function changeModelStoreWorkspace(workspaceId: string | null) {
console.log('Syncing models with new workspace', workspaceId);
const workspaceModels = await invoke<AnyModel[]>('plugin:yaak-models|workspace_models', {
const workspaceModelsStr = await invoke<string>('plugin:yaak-models|workspace_models', {
workspaceId, // NOTE: if no workspace id provided, it will just fetch global models
});
const workspaceModels = JSON.parse(workspaceModelsStr) as AnyModel[];
const data = newStoreData();
for (const model of workspaceModels) {
data[model.model][model.id] = model;

View File

@@ -94,7 +94,7 @@ pub(crate) fn get_settings<R: Runtime>(app_handle: AppHandle<R>) -> Result<Setti
pub(crate) fn workspace_models<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: Option<&str>,
) -> Result<Vec<AnyModel>> {
) -> Result<String> {
let db = window.db();
let mut l: Vec<AnyModel> = Vec::new();
@@ -109,7 +109,7 @@ pub(crate) fn workspace_models<R: Runtime>(
// 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());
@@ -120,5 +120,36 @@ pub(crate) fn workspace_models<R: Runtime>(
l.append(&mut db.list_workspace_metas(wid)?.into_iter().map(Into::into).collect());
}
Ok(l)
let j = serde_json::to_string(&l)?;
// NOTE: There's something weird that happens on Linux. If we send Cyrillic (or maybe other)
// unicode characters in this response (doesn't matter where) then the following bug happens:
// https://feedback.yaak.app/p/editing-the-url-sometimes-freezes-the-app
//
// It's as if every string resulting from the JSON.parse of the models gets encoded slightly
// wrong or something, causing the above bug where Codemirror can't calculate the cursor
// position anymore (even when none of the characters are included directly in the input).
//
// For some reason using escape sequences works, but it's a hacky fix. Hopefully the Linux
// webview dependency updates to a version where this bug doesn't exist, or we can use CEF
// (Chromium) for Linux in the future, which Tauri is working on.
Ok(escape_str_for_webview(&j))
}
fn escape_str_for_webview(input: &str) -> String {
input.chars().map(|c| {
let code = c as u32;
// ASCII
if code <= 0x7F {
c.to_string()
// BMP characters encoded normally
} else if code < 0xFFFF {
format!("\\u{:04X}", code)
// Beyond BMP encoded a surrogate pairs
} else {
let high = ((code - 0x10000) >> 10) + 0xD800;
let low = ((code - 0x10000) & 0x3FF) + 0xDC00;
format!("\\u{:04X}\\u{:04X}", high, low)
}
}).collect()
}

View File

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

View File

@@ -31,6 +31,9 @@ macro_rules! impl_model {
#[ts(export, export_to = "gen_models.ts")]
pub enum ProxySetting {
Enabled {
#[serde(default)]
// This was added after on so give it a default to be able to deserialize older values
disabled: bool,
http: String,
https: String,
auth: Option<ProxySettingAuth>,
@@ -486,11 +489,12 @@ pub struct Environment {
pub model: String,
pub id: String,
pub workspace_id: String,
pub environment_id: Option<String>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub name: String,
pub public: bool,
pub base: bool,
pub variables: Vec<EnvironmentVariable>,
}
@@ -523,9 +527,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 +538,9 @@ impl UpsertModelInfo for Environment {
fn update_columns() -> Vec<impl IntoIden> {
vec![
EnvironmentIden::UpdatedAt,
EnvironmentIden::Base,
EnvironmentIden::Name,
EnvironmentIden::Public,
EnvironmentIden::Variables,
]
}
@@ -547,10 +554,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(),
})
}
@@ -1989,6 +1997,7 @@ impl<'de> Deserialize<'de> for AnyModel {
Some(m) if m == "grpc_event" => AnyModel::GrpcEvent(fv(value).unwrap()),
Some(m) if m == "grpc_request" => AnyModel::GrpcRequest(fv(value).unwrap()),
Some(m) if m == "http_request" => AnyModel::HttpRequest(fv(value).unwrap()),
Some(m) if m == "http_response" => AnyModel::HttpResponse(fv(value).unwrap()),
Some(m) if m == "key_value" => AnyModel::KeyValue(fv(value).unwrap()),
Some(m) if m == "plugin" => AnyModel::Plugin(fv(value).unwrap()),
Some(m) if m == "settings" => AnyModel::Settings(fv(value).unwrap()),

View File

@@ -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<Environment> {
@@ -10,35 +11,41 @@ impl<'a> DbContext<'a> {
}
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(workspace_id)?;
let environments = self.list_environments_ensure_base(workspace_id)?;
let base_environments =
environments.into_iter().filter(|e| e.base).collect::<Vec<Environment>>();
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<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 =
self.find_many::<Environment>(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<Environment> {
for environment in
self.find_many::<Environment>(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<Environment> {
@@ -69,7 +76,7 @@ impl<'a> DbContext<'a> {
) -> Result<Environment> {
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<Environment> {
self.upsert(environment, source)
let cleaned_variables = environment
.variables
.iter()
.filter(|v| !v.name.is_empty() || !v.value.is_empty())
.cloned()
.collect::<Vec<EnvironmentVariable>>();
self.upsert(
&Environment {
variables: cleaned_variables,
..environment.clone()
},
source,
)
}
}

View File

@@ -11,6 +11,11 @@ impl<'a> DbContext<'a> {
let (sql, params) = Query::select()
.from(KeyValueIden::Table)
.column(Asterisk)
// Temporary clause to prevent bug when reverting to the previous version, before the
// ID column was added. A previous version will not know about ID and will create
// key/value entries that don't have one. This clause ensures they are not queried
// TODO: Add migration to delete key/values with NULL IDs later on, then remove this
.cond_where(Expr::col(KeyValueIden::Id).is_not_null())
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.prepare(sql.as_str())?;
let items = stmt.query_map(&*params.as_params(), KeyValue::from_row)?;

View File

@@ -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<WorkspaceMeta> {
@@ -23,10 +24,7 @@ impl<'a> DbContext<'a> {
Ok(workspace_metas)
}
pub fn get_or_create_workspace_meta(
&self,
workspace_id: &str,
) -> Result<WorkspaceMeta> {
pub fn get_or_create_workspace_meta(&self, workspace_id: &str) -> Result<WorkspaceMeta> {
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)
}

View File

@@ -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<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)?;
}
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)?;
}
for m in
self.find_many::<WebsocketRequest>(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::<Folder>(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)
}

View File

@@ -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<WebsocketRequest>,
}
pub async fn get_workspace_export_resources<R: Runtime>(
pub fn get_workspace_export_resources<R: Runtime>(
app_handle: &AppHandle<R>,
workspace_ids: Vec<&str>,
include_environments: bool,
include_private_environments: bool,
) -> Result<WorkspaceExport> {
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<R: Runtime>(
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)
}

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
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, };

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
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, };

View File

@@ -4,4 +4,4 @@ import type { SyncState } from "./gen_models.js";
export type FsCandidate = { "type": "FsCandidate", model: SyncModel, relPath: string, checksum: string, };
export type SyncOp = { "type": "fsCreate", model: SyncModel, } | { "type": "fsUpdate", model: SyncModel, state: SyncState, } | { "type": "fsDelete", state: SyncState, fs: FsCandidate | null, } | { "type": "dbCreate", fs: FsCandidate, } | { "type": "dbUpdate", state: SyncState, fs: FsCandidate, } | { "type": "dbDelete", model: SyncModel, state: SyncState, };
export type SyncOp = { "type": "fsCreate", model: SyncModel, } | { "type": "fsUpdate", model: SyncModel, state: SyncState, } | { "type": "fsDelete", state: SyncState, fs: FsCandidate | null, } | { "type": "dbCreate", fs: FsCandidate, } | { "type": "dbUpdate", state: SyncState, fs: FsCandidate, } | { "type": "dbDelete", model: SyncModel, state: SyncState, } | { "type": "ignorePrivate", model: SyncModel, };

View File

@@ -1,7 +1,7 @@
use crate::error::Result;
use crate::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<R: Runtime>(
workspace_id: &str,
sync_dir: &Path,
) -> Result<Vec<SyncOp>> {
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<R: Runtime>(
#[command]
pub async fn calculate_fs(dir: &Path) -> Result<Vec<SyncOp>> {
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<R: Runtime>(
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)]

View File

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

View File

@@ -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<R: Runtime>(
pub(crate) fn get_db_candidates<R: Runtime>(
app_handle: &AppHandle<R>,
workspace_id: &str,
sync_dir: &Path,
) -> Result<Vec<DbCandidate>> {
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<R: Runtime>(
// 1. Add candidates for models (created/modified/unmodified)
let mut candidates: Vec<DbCandidate> = 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<R: Runtime>(
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
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<R: Runtime>(
fn workspace_models<R: Runtime>(
app_handle: &AppHandle<R>,
workspace_id: &str,
) -> Result<Vec<SyncModel>> {
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<R: Runtime>(
Ok(sync_models)
}
pub(crate) async fn apply_sync_ops<R: Runtime>(
pub(crate) fn apply_sync_ops<R: Runtime>(
app_handle: &AppHandle<R>,
workspace_id: &str,
sync_dir: &Path,
@@ -328,13 +363,14 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
return Ok(Vec::new());
}
debug!(
info!(
"Applying sync ops {}",
sync_ops.iter().map(|op| op.to_string()).collect::<Vec<String>>().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<R: Runtime>(
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<R: Runtime>(
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<R: Runtime>(
// 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<R: Runtime>(
// 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<R: Runtime>(
// 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<R: Runtime>(
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<R: Runtime>(
pub(crate) fn apply_sync_state_ops<R: Runtime>(
app_handle: &AppHandle<R>,
workspace_id: &str,
sync_dir: &Path,
@@ -540,6 +574,9 @@ pub(crate) async fn apply_sync_state_ops<R: Runtime>(
SyncStateOp::Delete { state } => {
app_handle.db().delete_sync_state(&state)?;
}
SyncStateOp::NoOp => {
// Nothing
}
}
}
Ok(())

View File

@@ -4,6 +4,9 @@ version = "0.1.0"
edition = "2024"
publish = false
[package.metadata.wasm-pack.profile.release]
wasm-opt = false # Causes errors in CI (haven't figured out why yet)
[lib]
crate-type = ["cdylib", "rlib"]

View File

@@ -5,15 +5,15 @@ use log::{info, warn};
use std::str::FromStr;
use tauri::http::{HeaderMap, HeaderName};
use tauri::{AppHandle, Runtime, State, Url, WebviewWindow};
use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio::sync::{Mutex, mpsc};
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use yaak_http::apply_path_placeholders;
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::models::{
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
WebsocketEventType, WebsocketRequest,
};
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{
CallHttpAuthenticationRequest, HttpHeader, PluginWindowContext, RenderPurpose,
@@ -257,12 +257,16 @@ pub(crate) async fn connect<R: Runtime>(
// Add URL parameters to URL
let mut url = Url::parse(&url).unwrap();
{
let mut query_pairs = url.query_pairs_mut();
for p in url_parameters.clone() {
if !p.enabled || p.name.is_empty() {
continue;
let valid_query_pairs = url_parameters
.into_iter()
.filter(|p| p.enabled && !p.name.is_empty())
.collect::<Vec<_>>();
// NOTE: Only mutate query pairs if there are any, or it will append an empty `?` to the URL
if !valid_query_pairs.is_empty() {
let mut query_pairs = url.query_pairs_mut();
for p in valid_query_pairs {
query_pairs.append_pair(p.name.as_str(), p.value.as_str());
}
query_pairs.append_pair(p.name.as_str(), p.value.as_str());
}
}

View File

@@ -1,4 +1,4 @@
import { SettingsTab } from '../components/Settings/SettingsTab';
import type { SettingsTab } from '../components/Settings/Settings';
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { jotaiStore } from '../lib/jotai';
@@ -14,8 +14,9 @@ export const openSettings = createFastMutation<void, string, SettingsTab | null>
const location = router.buildLocation({
to: '/workspaces/$workspaceId/settings',
params: { workspaceId },
search: { tab: tab ?? SettingsTab.General },
search: { tab },
});
await invokeCmd('cmd_new_child_window', {
url: location.href,
label: 'settings',

View File

@@ -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<ButtonProps, 'onClick'> {
}
export function CopyButton({ text, ...props }: Props) {
const copy = useCopy({ disableToast: true });
const [copied, setCopied] = useTimedBoolean();
return (
<Button
@@ -23,8 +22,8 @@ export function CopyButton({ text, ...props }: Props) {
message: 'Failed to copy',
});
} else {
copy(content);
setCopied();
copyToClipboard(content, { disableToast: true });
setCopied();
}
}}
>

View File

@@ -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 { IconButtonProps } 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) {
const copy = useCopy({ disableToast: true });
const [copied, setCopied] = useTimedBoolean();
return (
<IconButton
@@ -25,7 +24,7 @@ export function CopyIconButton({ text, ...props }: Props) {
message: 'Failed to copy',
});
} else {
copy(content);
copyToClipboard(content, { disableToast: true });
setCopied();
}
}}

View File

@@ -31,7 +31,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
as="form"
space={3}
alignItems="start"
className="pb-3 max-h-[50vh]"
className="pb-3"
onSubmit={async (e) => {
e.preventDefault();
const workspaceId = await createGlobalModel({ model: 'workspace', name });

View File

@@ -2,6 +2,7 @@ import { useAtomValue } from 'jotai';
import React from 'react';
import { dialogsAtom, hideDialog } from '../lib/dialog';
import { Dialog, type DialogProps } from './core/Dialog';
import { ErrorBoundary } from './ErrorBoundary';
export type DialogInstance = {
id: string;
@@ -22,16 +23,17 @@ export function Dialogs() {
function DialogInstance({ render, onClose, id, ...props }: DialogInstance) {
const children = render({ hide: () => hideDialog(id) });
return (
<Dialog
open
key={id}
onClose={() => {
onClose?.();
hideDialog(id);
}}
{...props}
>
{children}
</Dialog>
<ErrorBoundary name={`Dialog ${id}`}>
<Dialog
open
onClose={() => {
onClose?.();
hideDialog(id);
}}
{...props}
>
{children}
</Dialog>
</ErrorBoundary>
);
}

View File

@@ -65,6 +65,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}
data={data}
className="pb-4" // Pad the bottom to look nice
/>
);
}
@@ -77,15 +78,17 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
setDataAttr,
data,
disabled,
className,
}: Pick<
Props<T>,
'inputs' | 'autocompleteFunctions' | 'autocompleteVariables' | 'stateKey' | 'data'
> & {
setDataAttr: (name: string, value: JsonPrimitive) => void;
disabled?: boolean;
className?: string;
}) {
return (
<VStack space={3} className="h-full overflow-auto">
<VStack space={3} className={classNames(className, 'h-full overflow-auto')}>
{inputs?.map((input, i) => {
if ('hidden' in input && input.hidden) {
return null;

View File

@@ -1,14 +1,13 @@
import {VStack} from "./core/Stacks";
import { VStack } from './core/Stacks';
export function EncryptionHelp() {
return <VStack space={3}>
<p>
Encrypt values like secrets and tokens. When enabled, Yaak will also encrypt HTTP responses,
cookies, and authentication credentials automatically.
</p>
<p>
Encrypted data remains secure when syncing to the filesystem or Git, and when exporting or
sharing with others.
</p>
return (
<VStack space={3}>
<p>Encrypt passwords, tokens, and other sensitive info when encryption is enabled.</p>
<p>
Encrypted data remains secure when syncing to the filesystem or Git, and when exporting or
sharing with others.
</p>
</VStack>
);
}

View File

@@ -1,5 +1,5 @@
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 classNames from 'classnames';
import type { ReactNode } from 'react';
@@ -12,6 +12,7 @@ import { useRandomKey } from '../hooks/useRandomKey';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { analyzeTemplate, convertTemplateToSecure } from '../lib/encryption';
import { showPrompt } from '../lib/prompt';
import { resolvedModelName } from '../lib/resolvedModelName';
import {
setupOrConfigureEncryption,
withEncryptionEnabled,
@@ -19,18 +20,21 @@ import {
import { BadgeButton } from './core/BadgeButton';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { DismissibleBanner } from './core/DismissibleBanner';
import type { DropdownItem } from './core/Dropdown';
import { ContextMenu } from './core/Dropdown';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { Heading } from './core/Heading';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { IconTooltip } from './core/IconTooltip';
import { InlineCode } from './core/InlineCode';
import type { PairWithId } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
import { VStack } from './core/Stacks';
interface Props {
initialEnvironment: Environment | null;
@@ -38,7 +42,8 @@ interface Props {
export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const createEnvironment = useCreateEnvironment();
const { baseEnvironment, subEnvironments, allEnvironments } = useEnvironmentsBreakdown();
const { baseEnvironment, otherBaseEnvironments, subEnvironments, allEnvironments } =
useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
initialEnvironment?.id ?? null,
);
@@ -51,9 +56,36 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const handleCreateEnvironment = async () => {
if (baseEnvironment == null) return;
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 (
<SplitLayout
name="env_editor"
@@ -63,24 +95,33 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
firstSlot={() => (
<aside className="w-full min-w-0 pt-2">
<div className="min-w-0 h-full overflow-y-auto pt-1">
<SidebarButton
active={selectedEnvironment?.id == baseEnvironment?.id}
onClick={() => setSelectedEnvironmentId(null)}
environment={null}
rightSlot={
<IconButton
size="sm"
iconSize="md"
title="Add sub environment"
icon="plus_circle"
iconClassName="text-text-subtlest group-hover:text-text-subtle"
className="group"
onClick={handleCreateEnvironment}
/>
}
>
{baseEnvironment?.name}
</SidebarButton>
{[baseEnvironment, ...otherBaseEnvironments].map((e) => (
<SidebarButton
key={e.id}
active={selectedEnvironment?.id == e.id}
onClick={() => setSelectedEnvironmentId(e.id)}
environment={e}
duplicateEnvironment={handleDuplicateEnvironment}
// Allow deleting base environment if there are multiples
deleteEnvironment={
otherBaseEnvironments.length > 0 ? handleDeleteEnvironment : null
}
rightSlot={e.public && sharableTooltip}
outerRightSlot={
<IconButton
size="sm"
iconSize="md"
title="Add sub environment"
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 && (
<div className="px-2">
<Separator className="my-3"></Separator>
@@ -92,11 +133,9 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
active={selectedEnvironment?.id === e.id}
environment={e}
onClick={() => setSelectedEnvironmentId(e.id)}
onDelete={() => {
if (e.id === selectedEnvironmentId) {
setSelectedEnvironmentId(null);
}
}}
rightSlot={e.public && sharableTooltip}
duplicateEnvironment={handleDuplicateEnvironment}
deleteEnvironment={handleDeleteEnvironment}
>
{e.name}
</SidebarButton>
@@ -143,11 +182,10 @@ const EnvironmentEditor = function ({
);
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 options: GenericCompletionOption[] = [];
const isBaseEnv = activeEnvironment.environmentId == null;
if (isBaseEnv) {
if (activeEnvironment.base) {
return { options };
}
@@ -166,10 +204,10 @@ const EnvironmentEditor = function ({
});
}
return { options };
}, [activeEnvironment.environmentId, activeEnvironment.id, allEnvironments]);
}, [activeEnvironment.base, activeEnvironment.id, allEnvironments]);
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;
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 promptToEncrypt = useMemo(() => {
if (!isEncryptionEnabled) {
return false;
return true;
} else {
return !activeEnvironment.variables.every(
(v) => v.value === '' || analyzeTemplate(v.value) !== 'insecure',
@@ -199,28 +237,37 @@ const EnvironmentEditor = function ({
return (
<VStack space={4} className={classNames(className, 'pl-4')}>
<HStack space={2} className="justify-between">
<Heading className="w-full flex items-center gap-1">
<div>{activeEnvironment?.name}</div>
{promptToEncrypt ? (
<Heading className="w-full flex items-center gap-0.5">
<div className="mr-2">{activeEnvironment?.name}</div>
{isEncryptionEnabled ? (
promptToEncrypt ? (
<BadgeButton color="notice" onClick={() => encryptEnvironment(activeEnvironment)}>
Encrypt All Variables
</BadgeButton>
) : isEncryptionEnabled ? (
) : (
<BadgeButton color="secondary" onClick={setupOrConfigureEncryption}>
Encryption Settings
</BadgeButton>
) : (
<IconButton
size="sm"
icon={valueVisibility.value ? 'eye' : 'eye_closed'}
title={valueVisibility.value ? 'Hide Values' : 'Reveal Values'}
onClick={() => valueVisibility.set((v) => !v)}
/>
)}
</Heading>
</HStack>
<div className="h-full pr-2 pb-2">
)
) : (
<>
<BadgeButton color="secondary" onClick={() => valueVisibility.set((v) => !v)}>
{valueVisibility.value ? 'Conceal Values' : 'Reveal Values'}
</BadgeButton>
</>
)}
</Heading>
{activeEnvironment.public && promptToEncrypt && (
<DismissibleBanner
id={`warn-unencrypted-${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
allowMultilineValues
preferenceName="environment"
@@ -230,6 +277,7 @@ const EnvironmentEditor = function ({
valueType={valueType}
valueAutocompleteVariables
valueAutocompleteFunctions
forcedEnvironmentId={activeEnvironment.id}
forceUpdateKey={`${activeEnvironment.id}::${forceUpdateKey}`}
pairs={activeEnvironment.variables}
onChange={handleChange}
@@ -245,17 +293,21 @@ function SidebarButton({
className,
active,
onClick,
onDelete,
deleteEnvironment,
rightSlot,
outerRightSlot,
duplicateEnvironment,
environment,
}: {
className?: string;
children: ReactNode;
active: boolean;
onClick: () => void;
onDelete?: () => void;
rightSlot?: ReactNode;
environment: Environment | null;
outerRightSlot?: ReactNode;
environment: Environment;
deleteEnvironment: ((environment: Environment) => void) | null;
duplicateEnvironment: ((environment: Environment) => void) | null;
}) {
const [showContextMenu, setShowContextMenu] = useState<{
x: number;
@@ -287,49 +339,88 @@ function SidebarButton({
justify="start"
onClick={onClick}
onContextMenu={handleContextMenu}
rightSlot={rightSlot}
>
{children}
</Button>
{rightSlot}
{outerRightSlot}
</div>
{environment != null && (
<ContextMenu
triggerPosition={showContextMenu}
onClose={() => setShowContextMenu(null)}
items={[
{
label: 'Rename',
leftSlot: <Icon icon="pencil" size="sm" />,
onSelect: async () => {
const name = await showPrompt({
id: 'rename-environment',
title: 'Rename Environment',
description: (
<>
Enter a new name for <InlineCode>{environment.name}</InlineCode>
</>
),
label: 'Name',
confirmText: 'Save',
placeholder: 'New Name',
defaultValue: environment.name,
});
if (name == null) return;
await patchModel(environment, { name });
},
<ContextMenu
triggerPosition={showContextMenu}
onClose={() => setShowContextMenu(null)}
items={[
{
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
hidden: environment.base,
onSelect: async () => {
const name = await showPrompt({
id: 'rename-environment',
title: 'Rename Environment',
description: (
<>
Enter a new name for <InlineCode>{environment.name}</InlineCode>
</>
),
label: 'Name',
confirmText: 'Save',
placeholder: 'New Name',
defaultValue: environment.name,
});
if (name == null) return;
await patchModel(environment, { name });
},
{
color: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" size="sm" />,
onSelect: async () => {
await deleteModelWithConfirm(environment);
onDelete?.();
},
},
...((duplicateEnvironment
? [
{
label: 'Duplicate',
leftSlot: <Icon icon="copy" />,
onSelect: () => {
duplicateEnvironment?.(environment);
},
},
]
: []) as DropdownItem[]),
{
label: `Make ${environment.public ? 'Private' : 'Sharable'}`,
leftSlot: <Icon icon={environment.public ? 'eye_closed' : 'eye'} />,
rightSlot: (
<IconTooltip
content={
<>
Sharable environments will be included in Directory Sync or data export. It is
recommended to encrypt all variable values within sharable environments to
prevent accidentally leaking secrets.
</>
}
/>
),
onSelect: async () => {
await patchModel(environment, { public: !environment.public });
},
]}
/>
)}
},
...((deleteEnvironment
? [
{
color: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: () => {
deleteEnvironment(environment);
},
},
]
: []) as DropdownItem[]),
]}
/>
</>
);
}
const sharableTooltip = (
<IconTooltip
icon="eye"
content="This environment will be included in Directory Sync and data exports"
/>
);

View File

@@ -0,0 +1,68 @@
import type { ErrorInfo, ReactNode } from 'react';
import { Component, useEffect } from 'react';
import { showDialog } from '../lib/dialog';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode';
import RouteError from './RouteError';
interface ErrorBoundaryProps {
name: string;
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.warn('Error caught by ErrorBoundary:', error, info);
}
render() {
if (this.state.hasError) {
return (
<Banner color="danger" className="flex items-center gap-2">
<div>
Error rendering <InlineCode>{this.props.name}</InlineCode> component
</div>
<Button
className="inline-flex"
variant="border"
color="danger"
size="2xs"
onClick={() => {
showDialog({
id: 'error-boundary',
render: () => <RouteError error={this.state.error} />,
});
}}
>
Show
</Button>
</Banner>
);
}
return this.props.children;
}
}
export function ErrorBoundaryTestThrow() {
useEffect(() => {
throw new Error('test error');
});
return <div>Hello</div>;
}

View File

@@ -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<boolean>(false);
const [includePrivateEnvironments, setIncludePrivateEnvironments] = useState<boolean>(false);
const [selectedWorkspaces, setSelectedWorkspaces] = useState<Record<string, boolean>>({
[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({
<summary className="px-3 py-2">Extra Settings</summary>
<div className="px-3 pb-2">
<Checkbox
checked={includeEnvironments}
onChange={setIncludeEnvironments}
title="Include environments"
checked={includePrivateEnvironments}
onChange={setIncludePrivateEnvironments}
title="Include private environments"
help='Environments marked as "sharable" will be exported by default'
/>
</div>
</details>

View File

@@ -1,6 +1,6 @@
import type { EditorView } from '@codemirror/view';
import type { HttpRequest } from '@yaakapp-internal/models';
import { updateSchema } from 'cm6-graphql';
import type { EditorView } from 'codemirror';
import { formatSdl } from 'format-graphql';
import { useEffect, useMemo, useRef } from 'react';
@@ -60,7 +60,7 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
// Refetch the schema when the URL changes
useEffect(() => {
if (editorViewRef.current === null) return;
if (editorViewRef.current == null) return;
updateSchema(editorViewRef.current, schema ?? undefined);
}, [schema]);

View File

@@ -1,8 +1,8 @@
import { jsonLanguage } from '@codemirror/lang-json';
import { linter } from '@codemirror/lint';
import type { EditorView } from '@codemirror/view';
import type { GrpcRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import {
handleRefresh,
jsonCompletion,

View File

@@ -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,20 +11,22 @@ 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';
import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary';
import { RecentGrpcConnectionsDropdown } from './RecentGrpcConnectionsDropdown';
import { HotKeyList } from './core/HotKeyList';
interface Props {
style?: CSSProperties;
@@ -48,7 +49,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,
@@ -93,27 +93,29 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
/>
</div>
</HStack>
<AutoScroller
data={events}
header={
activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)
}
render={(event) => (
<EventRow
key={event.id}
event={event}
isActive={event.id === activeEventId}
onClick={() => {
if (event.id === activeEventId) setActiveEventId(null);
else setActiveEventId(event.id);
}}
/>
)}
/>
<ErrorBoundary name="GRPC Events">
<AutoScroller
data={events}
header={
activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)
}
render={(event) => (
<EventRow
key={event.id}
event={event}
isActive={event.id === activeEventId}
onClick={() => {
if (event.id === activeEventId) setActiveEventId(null);
else setActiveEventId(event.id);
}}
/>
)}
/>
</ErrorBoundary>
</div>
)
}
@@ -136,7 +138,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
title="Copy message"
icon="copy"
size="xs"
onClick={() => copy(activeEvent.content)}
onClick={() => copyToClipboard(activeEvent.content)}
/>
</div>
{!showLarge && activeEvent.content.length > 1000 * 1000 ? (
@@ -161,7 +163,13 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
</div>
</VStack>
) : (
<JsonAttributeTree attrValue={JSON.parse(activeEvent?.content ?? '{}')} />
<Editor
language="json"
defaultValue={activeEvent.content ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</>
) : (

View File

@@ -44,12 +44,12 @@ import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { FormMultipartEditor } from './FormMultipartEditor';
import { FormUrlencodedEditor } from './FormUrlencodedEditor';
import { GraphQLEditor } from './GraphQLEditor';
import { HeadersEditor } from './HeadersEditor';
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
import { MarkdownEditor } from './MarkdownEditor';
import { UrlBar } from './UrlBar';
import { UrlParametersEditor } from './UrlParameterEditor';
import { GraphQLEditor } from './GraphQLEditor';
interface Props {
style: CSSProperties;

View File

@@ -30,6 +30,7 @@ import { ImageViewer } from './responseViewers/ImageViewer';
import { PdfViewer } from './responseViewers/PdfViewer';
import { SvgViewer } from './responseViewers/SvgViewer';
import { VideoViewer } from './responseViewers/VideoViewer';
import { ErrorBoundary } from './ErrorBoundary';
interface Props {
style?: CSSProperties;
@@ -155,35 +156,37 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
tabListClassName="mt-1.5"
>
<TabContent value={TAB_BODY}>
<ConfirmLargeResponse response={activeResponse}>
{activeResponse.state === 'initialized' ? (
<EmptyStateText>
<LoadingIcon size="xl" className="text-text-subtlest" />
</EmptyStateText>
) : activeResponse.state === 'closed' && activeResponse.contentLength === 0 ? (
<EmptyStateText>Empty </EmptyStateText>
) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? (
<EventStreamViewer response={activeResponse} />
) : mimeType?.match(/^image\/svg/) ? (
<SvgViewer response={activeResponse} />
) : mimeType?.match(/^image/i) ? (
<EnsureCompleteResponse response={activeResponse} render={ImageViewer} />
) : mimeType?.match(/^audio/i) ? (
<EnsureCompleteResponse response={activeResponse} render={AudioViewer} />
) : mimeType?.match(/^video/i) ? (
<EnsureCompleteResponse response={activeResponse} render={VideoViewer} />
) : mimeType?.match(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} render={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer
textViewerClassName="-mr-2 bg-surface" // Pull to the right
response={activeResponse}
pretty={viewMode === 'pretty'}
/>
)}
</ConfirmLargeResponse>
<ErrorBoundary name="Http Response Viewer">
<ConfirmLargeResponse response={activeResponse}>
{activeResponse.state === 'initialized' ? (
<EmptyStateText>
<LoadingIcon size="xl" className="text-text-subtlest" />
</EmptyStateText>
) : activeResponse.state === 'closed' && activeResponse.contentLength === 0 ? (
<EmptyStateText>Empty </EmptyStateText>
) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? (
<EventStreamViewer response={activeResponse} />
) : mimeType?.match(/^image\/svg/) ? (
<SvgViewer response={activeResponse} />
) : mimeType?.match(/^image/i) ? (
<EnsureCompleteResponse response={activeResponse} render={ImageViewer} />
) : mimeType?.match(/^audio/i) ? (
<EnsureCompleteResponse response={activeResponse} render={AudioViewer} />
) : mimeType?.match(/^video/i) ? (
<EnsureCompleteResponse response={activeResponse} render={VideoViewer} />
) : mimeType?.match(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} render={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer
textViewerClassName="-mr-2 bg-surface" // Pull to the right
response={activeResponse}
pretty={viewMode === 'pretty'}
/>
)}
</ConfirmLargeResponse>
</ErrorBoundary>
</TabContent>
<TabContent value={TAB_HEADERS}>
<ResponseHeaders response={activeResponse} />

View File

@@ -6,7 +6,6 @@ import { appInfo } from '../hooks/useAppInfo';
import { useLicenseConfirmation } from '../hooks/useLicenseConfirmation';
import { BadgeButton } from './core/BadgeButton';
import type { ButtonProps } from './core/Button';
import { SettingsTab } from './Settings/SettingsTab';
const details: Record<
LicenseCheckStatus['type'],
@@ -28,7 +27,7 @@ export function LicenseBadge() {
if (check.error) {
return (
<BadgeButton color="danger" onClick={() => openSettings.mutate(SettingsTab.License)}>
<BadgeButton color="danger" onClick={() => openSettings.mutate('license')}>
License Error
</BadgeButton>
);
@@ -64,7 +63,7 @@ export function LicenseBadge() {
hasDismissedTrial: true,
}));
}
openSettings.mutate(SettingsTab.License);
openSettings.mutate('license');
}}
>
{detail.label}

View File

@@ -1,13 +1,16 @@
import remarkGfm from 'remark-gfm';
import ReactMarkdown, { type Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ErrorBoundary } from './ErrorBoundary';
import { Prose } from './Prose';
export function Markdown({ children, className }: { children: string; className?: string }) {
return (
<Prose className={className}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{children}
</ReactMarkdown>
<ErrorBoundary name="Markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{children}
</ReactMarkdown>
</ErrorBoundary>
</Prose>
);
}

View File

@@ -32,6 +32,7 @@ export function RedirectToLatestWorkspace() {
request_id: requestId,
};
console.log("Redirecting to workspace", params, search);
await router.navigate({ to: '/workspaces/$workspaceId', params, search });
})();
}, [recentWorkspaces, workspaces, workspaces.length]);

View File

@@ -32,8 +32,8 @@ export function ResizeHandle({
className={classNames(
className,
'group z-10 flex select-none',
// 'bg-fg-info', // For debugging
vertical ? 'w-full h-3 cursor-row-resize' : 'h-full w-3 cursor-col-resize',
// 'bg-info', // For debugging
vertical ? 'w-full h-2 cursor-row-resize' : 'h-full w-2 cursor-col-resize',
justify === 'center' && 'justify-center',
justify === 'end' && 'justify-end',
justify === 'start' && 'justify-start',

View File

@@ -3,7 +3,7 @@ import { FormattedError } from './core/FormattedError';
import { Heading } from './core/Heading';
import { VStack } from './core/Stacks';
export default function RouteError({ error }: { error: unknown; reset: () => void }) {
export default function RouteError({ error }: { error: unknown }) {
console.log('Error', error);
const stringified = JSON.stringify(error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -13,24 +13,23 @@ import { SettingsGeneral } from './SettingsGeneral';
import { SettingsLicense } from './SettingsLicense';
import { SettingsPlugins } from './SettingsPlugins';
import { SettingsProxy } from './SettingsProxy';
import { SettingsTab } from './SettingsTab';
interface Props {
hide?: () => void;
}
const tabs = [
SettingsTab.General,
SettingsTab.Appearance,
SettingsTab.Proxy,
SettingsTab.Plugins,
SettingsTab.License,
];
const TAB_GENERAL = 'general';
const TAB_APPEARANCE = 'appearance';
const TAB_PROXY = 'proxy';
const TAB_PLUGINS = 'plugins';
const TAB_LICENSE = 'license';
const tabs = [TAB_GENERAL, TAB_APPEARANCE, TAB_PROXY, TAB_PLUGINS, TAB_LICENSE] as const;
export type SettingsTab = (typeof tabs)[number];
export default function Settings({ hide }: Props) {
const osInfo = useOsInfo();
const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' });
const [tab, setTab] = useState<string>(tabFromQuery ?? SettingsTab.General);
const [tab, setTab] = useState<string | undefined>(tabFromQuery);
// Close settings window on escape
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
@@ -74,19 +73,19 @@ export default function Settings({ hide }: Props) {
onChangeValue={setTab}
tabs={tabs.map((value) => ({ value, label: capitalize(value) }))}
>
<TabContent value={SettingsTab.General} className="pt-3 overflow-y-auto h-full px-4">
<TabContent value={TAB_GENERAL} className="pt-3 overflow-y-auto h-full px-4">
<SettingsGeneral />
</TabContent>
<TabContent value={SettingsTab.Appearance} className="pt-3 overflow-y-auto h-full px-4">
<TabContent value={TAB_APPEARANCE} className="pt-3 overflow-y-auto h-full px-4">
<SettingsAppearance />
</TabContent>
<TabContent value={SettingsTab.Plugins} className="pt-3 overflow-y-auto h-full px-4">
<TabContent value={TAB_PLUGINS} className="pt-3 overflow-y-auto h-full px-4">
<SettingsPlugins />
</TabContent>
<TabContent value={SettingsTab.Proxy} className="pt-3 overflow-y-auto h-full px-4">
<TabContent value={TAB_PROXY} className="pt-3 overflow-y-auto h-full px-4">
<SettingsProxy />
</TabContent>
<TabContent value={SettingsTab.License} className="pt-3 overflow-y-auto h-full px-4">
<TabContent value={TAB_LICENSE} className="pt-3 overflow-y-auto h-full px-4">
<SettingsLicense />
</TabContent>
</Tabs>

View File

@@ -24,6 +24,7 @@ export function SettingsProxy() {
} else if (v === 'enabled') {
await patchModel(settings, {
proxy: {
disabled: false,
type: 'enabled',
http: '',
https: '',
@@ -42,16 +43,42 @@ export function SettingsProxy() {
/>
{settings.proxy?.type === 'enabled' && (
<VStack space={1.5}>
<HStack space={1.5} className="mt-3">
<Checkbox
className="my-3"
checked={!settings.proxy.disabled}
title="Enable proxy"
help="Use this to temporarily disable the proxy without losing the configuration"
onChange={async (enabled) => {
const { proxy } = settings;
const http = proxy?.type === 'enabled' ? proxy.http : '';
const https = proxy?.type === 'enabled' ? proxy.https : '';
const auth = proxy?.type === 'enabled' ? proxy.auth : null;
const disabled = !enabled;
await patchModel(settings, {
proxy: { type: 'enabled', http, https, auth, disabled },
});
}}
/>
<HStack space={1.5}>
<PlainInput
size="sm"
label="HTTP"
placeholder="localhost:9090"
defaultValue={settings.proxy?.http}
onChange={async (http) => {
const https = settings.proxy?.type === 'enabled' ? settings.proxy.https : '';
const auth = settings.proxy?.type === 'enabled' ? settings.proxy.auth : null;
await patchModel(settings, { proxy: { type: 'enabled', http, https, auth } });
const { proxy } = settings;
const https = proxy?.type === 'enabled' ? proxy.https : '';
const auth = proxy?.type === 'enabled' ? proxy.auth : null;
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
await patchModel(settings, {
proxy: {
type: 'enabled',
http,
https,
auth,
disabled,
},
});
}}
/>
<PlainInput
@@ -60,9 +87,13 @@ export function SettingsProxy() {
placeholder="localhost:9090"
defaultValue={settings.proxy?.https}
onChange={async (https) => {
const http = settings.proxy?.type === 'enabled' ? settings.proxy.http : '';
const auth = settings.proxy?.type === 'enabled' ? settings.proxy.auth : null;
await patchModel(settings, { proxy: { type: 'enabled', http, https, auth } });
const { proxy } = settings;
const http = proxy?.type === 'enabled' ? proxy.http : '';
const auth = proxy?.type === 'enabled' ? proxy.auth : null;
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
await patchModel(settings, {
proxy: { type: 'enabled', http, https, auth, disabled },
});
}}
/>
</HStack>
@@ -71,10 +102,14 @@ export function SettingsProxy() {
checked={settings.proxy.auth != null}
title="Enable authentication"
onChange={async (enabled) => {
const http = settings.proxy?.type === 'enabled' ? settings.proxy.http : '';
const https = settings.proxy?.type === 'enabled' ? settings.proxy.https : '';
const { proxy } = settings;
const http = proxy?.type === 'enabled' ? proxy.http : '';
const https = proxy?.type === 'enabled' ? proxy.https : '';
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
const auth = enabled ? { user: '', password: '' } : null;
await patchModel(settings, { proxy: { type: 'enabled', http, https, auth } });
await patchModel(settings, {
proxy: { type: 'enabled', http, https, auth, disabled },
});
}}
/>
@@ -87,12 +122,15 @@ export function SettingsProxy() {
placeholder="myUser"
defaultValue={settings.proxy.auth.user}
onChange={async (user) => {
const https = settings.proxy?.type === 'enabled' ? settings.proxy.https : '';
const http = settings.proxy?.type === 'enabled' ? settings.proxy.http : '';
const password =
settings.proxy?.type === 'enabled' ? (settings.proxy.auth?.password ?? '') : '';
const { proxy } = settings;
const http = proxy?.type === 'enabled' ? proxy.http : '';
const https = proxy?.type === 'enabled' ? proxy.https : '';
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
const password = proxy?.type === 'enabled' ? (proxy.auth?.password ?? '') : '';
const auth = { user, password };
await patchModel(settings, { proxy: { type: 'enabled', http, https, auth } });
await patchModel(settings, {
proxy: { type: 'enabled', http, https, auth, disabled },
});
}}
/>
<PlainInput
@@ -102,12 +140,15 @@ export function SettingsProxy() {
placeholder="s3cretPassw0rd"
defaultValue={settings.proxy.auth.password}
onChange={async (password) => {
const https = settings.proxy?.type === 'enabled' ? settings.proxy.https : '';
const http = settings.proxy?.type === 'enabled' ? settings.proxy.http : '';
const user =
settings.proxy?.type === 'enabled' ? (settings.proxy.auth?.user ?? '') : '';
const { proxy } = settings;
const http = proxy?.type === 'enabled' ? proxy.http : '';
const https = proxy?.type === 'enabled' ? proxy.https : '';
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
const user = proxy?.type === 'enabled' ? (proxy.auth?.user ?? '') : '';
const auth = { user, password };
await patchModel(settings, { proxy: { type: 'enabled', http, https, auth } });
await patchModel(settings, {
proxy: { type: 'enabled', http, https, auth, disabled },
});
}}
/>
</HStack>

View File

@@ -1,8 +0,0 @@
export enum SettingsTab {
General = 'general',
Proxy = 'proxy',
Appearance = 'appearance',
Plugins = 'plugins',
License = 'license',
}

View File

@@ -13,7 +13,6 @@ import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { KeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
import { SettingsTab } from './Settings/SettingsTab';
export function SettingsDropdown() {
const importData = useImportData();
@@ -64,7 +63,7 @@ export function SettingsDropdown() {
color: 'success',
hidden: check.data == null || check.data.type === 'commercial_use',
leftSlot: <Icon icon="circle_dollar_sign" />,
onSelect: () => openSettings.mutate(SettingsTab.License),
onSelect: () => openSettings.mutate('license'),
},
{
label: 'Check for Updates',
@@ -74,13 +73,13 @@ export function SettingsDropdown() {
{
label: 'Feedback',
leftSlot: <Icon icon="chat" />,
rightSlot: <Icon icon="external_link" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl('https://yaak.app/feedback'),
},
{
label: 'Changelog',
leftSlot: <Icon icon="cake" />,
rightSlot: <Icon icon="external_link" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl(`https://yaak.app/changelog/${appInfo.version}`),
},
]}

View File

@@ -4,6 +4,7 @@ import React, { type ReactNode } from 'react';
import { hideToast, toastsAtom } from '../lib/toast';
import { Toast, type ToastProps } from './core/Toast';
import { Portal } from './Portal';
import { ErrorBoundary } from './ErrorBoundary';
export type ToastInstance = {
id: string;
@@ -22,16 +23,17 @@ export const Toasts = () => {
{toasts.map((toast: ToastInstance) => {
const { message, uniqueKey, ...props } = toast;
return (
<Toast
key={uniqueKey}
open
{...props}
// We call onClose inside actions.hide instead of passing to toast so that
// it gets called from external close calls as well
onClose={() => hideToast(toast)}
>
{message}
</Toast>
<ErrorBoundary key={uniqueKey} name={`Toast ${uniqueKey}`}>
<Toast
open
{...props}
// We call onClose inside actions.hide instead of passing to toast so that
// it gets called from external close calls as well
onClose={() => hideToast(toast)}
>
{message}
</Toast>
</ErrorBoundary>
);
})}
</AnimatePresence>

View File

@@ -1,6 +1,6 @@
import type { EditorView } from '@codemirror/view';
import type { HttpRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import type { FormEvent, ReactNode } from 'react';
import { memo, useRef, useState } from 'react';
import { useHotKey } from '../hooks/useHotKey';
@@ -8,7 +8,7 @@ import type { IconProps } from './core/Icon';
import { IconButton } from './core/IconButton';
import type { InputProps } from './core/Input';
import { Input } from './core/Input';
import {HStack} from "./core/Stacks";
import { HStack } from './core/Stacks';
import { RequestMethodDropdown } from './RequestMethodDropdown';
type Props = Pick<HttpRequest, 'url'> & {

View File

@@ -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';
@@ -27,6 +27,7 @@ import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
import { WebsocketStatusTag } from './core/WebsocketStatusTag';
import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary';
import { RecentWebsocketConnectionsDropdown } from './RecentWebsocketConnectionsDropdown';
interface Props {
@@ -41,7 +42,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 +63,6 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
const language = languageFromContentType(null, message);
const formattedMessage = useFormatText({ language, text: message, pretty: true });
const copy = useCopy();
return (
<SplitLayout
@@ -95,27 +94,29 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
/>
</HStack>
</HStack>
<AutoScroller
data={events}
header={
activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)
}
render={(event) => (
<EventRow
key={event.id}
event={event}
isActive={event.id === activeEventId}
onClick={() => {
if (event.id === activeEventId) setActiveEventId(null);
else setActiveEventId(event.id);
}}
/>
)}
/>
<ErrorBoundary name="Websocket Events">
<AutoScroller
data={events}
header={
activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)
}
render={(event) => (
<EventRow
key={event.id}
event={event}
isActive={event.id === activeEventId}
onClick={() => {
if (event.id === activeEventId) setActiveEventId(null);
else setActiveEventId(event.id);
}}
/>
)}
/>
</ErrorBoundary>
</div>
)
}
@@ -151,7 +152,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
title="Copy message"
icon="copy"
size="xs"
onClick={() => copy(formattedMessage.data ?? '')}
onClick={() => copyToClipboard(formattedMessage.data ?? '')}
/>
</HStack>
)}

View File

@@ -41,6 +41,7 @@ import { Sidebar } from './sidebar/Sidebar';
import { SidebarActions } from './sidebar/SidebarActions';
import { WebsocketRequestLayout } from './WebsocketRequestLayout';
import { WorkspaceHeader } from './WorkspaceHeader';
import { ErrorBoundary } from './ErrorBoundary';
const side = { gridArea: 'side' };
const head = { gridArea: 'head' };
@@ -149,16 +150,20 @@ export function Workspace() {
<HeaderSize size="lg" className="border-transparent">
<SidebarActions />
</HeaderSize>
<Sidebar />
<ErrorBoundary name="Sidebar (Floating)">
<Sidebar />
</ErrorBoundary>
</m.div>
</Overlay>
) : (
<>
<div style={side} className={classNames('x-theme-sidebar', 'overflow-hidden bg-surface')}>
<Sidebar className="border-r border-border-subtle" />
<ErrorBoundary name="Sidebar">
<Sidebar className="border-r border-border-subtle" />
</ErrorBoundary>
</div>
<ResizeHandle
className="-translate-x-3"
className="-translate-x-0.5"
justify="end"
side="right"
isResizing={isResizing}
@@ -175,7 +180,9 @@ export function Workspace() {
>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
<WorkspaceBody />
<ErrorBoundary name="Workspace Body">
<WorkspaceBody />
</ErrorBoundary>
</div>
);
}
@@ -187,12 +194,18 @@ function WorkspaceBody() {
if (activeWorkspace == null) {
return (
<div className="m-auto">
<m.div
className="m-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
// Delay the entering because the workspaces might load after a slight delay
transition={{ delay: 0.5 }}
>
<Banner color="warning" className="max-w-[30rem]">
The active workspace was not found. Select a workspace from the header menu or report this
bug to <FeedbackLink />
</Banner>
</div>
</m.div>
);
}

View File

@@ -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 (
<EnterWorkspaceKey
workspaceMeta={workspaceMeta}
error={key.error}
onEnabled={() => {
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 = (
<KeyRevealer
disableLabel={justEnabledEncryption}
defaultShow={justEnabledEncryption}
workspaceId={workspaceMeta.workspaceId}
encryptionKey={key.key}
/>
);
return (
@@ -63,10 +90,13 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
)}
{keyRevealer}
{onDone && (
<Button color="secondary" onClick={() => {
<Button
color="secondary"
onClick={() => {
onDone();
onEnabledEncryption?.();
}}>
}}
>
Done
</Button>
)}
@@ -74,6 +104,7 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
);
}
// Show button to enable encryption
return (
<div className="mb-auto flex flex-col-reverse">
<Button
@@ -107,17 +138,23 @@ const setWorkspaceKeyMut = createFastMutation({
function EnterWorkspaceKey({
workspaceMeta,
onEnabled,
error,
}: {
workspaceMeta: WorkspaceMeta;
onEnabled?: () => void;
error?: string | null;
}) {
const [key, setKey] = useState<string>('');
return (
<VStack space={4}>
<Banner color="info">
This workspace contains encrypted values but no key is configured. Please enter the
workspace key to access the encrypted data.
</Banner>
<VStack space={4} className="w-full">
{error ? (
<Banner color="danger">{error}</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
as="form"
alignItems="end"
@@ -149,23 +186,16 @@ function EnterWorkspaceKey({
}
function KeyRevealer({
workspaceId,
defaultShow = false,
disableLabel = false,
encryptionKey,
}: {
workspaceId: string;
defaultShow?: boolean;
disableLabel?: boolean;
encryptionKey: string;
}) {
const [key, setKey] = useState<string | null>(null);
const [show, setShow] = useStateWithDeps<boolean>(defaultShow, [defaultShow]);
useEffect(() => {
revealWorkspaceKey(workspaceId).then(setKey);
}, [setKey, workspaceId]);
if (key == null) return null;
return (
<div
className={classNames(
@@ -180,10 +210,10 @@ function KeyRevealer({
<IconTooltip iconSize="sm" size="lg" content={helpAfterEncryption} />
</span>
)}
{key && <HighlightedKey keyText={key} show={show} />}
{encryptionKey && <HighlightedKey keyText={encryptionKey} show={show} />}
</VStack>
<HStack>
{key && <CopyIconButton text={key} title="Copy workspace key" />}
{encryptionKey && <CopyIconButton text={encryptionKey} title="Copy workspace key" />}
<IconButton
title={show ? 'Hide' : 'Reveal' + 'workspace key'}
icon={show ? 'eye_closed' : 'eye'}
@@ -220,8 +250,8 @@ function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) {
const helpAfterEncryption = (
<p>
this key is used for any encryption used for this workspace. It is stored securely using your OS
keychain, but it is recommended to back it up. If you share this workspace with others,
you&apos;ll need to send them this key to access any encrypted values.
The following key is used for encryption operations within this workspace. It is stored securely
using your OS keychain, but it is recommended to back it up. If you share this workspace with
others, you&apos;ll need to send them this key to access any encrypted values.
</p>
);

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ export function Checkbox({
<HStack
as="label"
alignItems="center"
space={2}
space={3}
className={classNames(className, 'text-text mr-auto')}
>
<div className={classNames(inputWrapperClassName, 'x-theme-input', 'relative flex')}>
@@ -56,9 +56,11 @@ export function Checkbox({
/>
</div>
</div>
<div className={classNames(fullWidth && 'w-full', disabled && 'opacity-disabled')}>
{!hideLabel && title}
</div>
{!hideLabel && (
<div className={classNames(fullWidth && 'w-full', disabled && 'opacity-disabled')}>
{title}
</div>
)}
{help && <IconTooltip content={help} />}
</HStack>
);

View File

@@ -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 (
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<div
role="dialog"
className={classNames(
@@ -64,6 +54,16 @@ export function Dialog({
)}
aria-labelledby={titleId}
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
initial={{ top: 5, scale: 0.97 }}

View File

@@ -2,7 +2,7 @@ import classNames from 'classnames';
import { useKeyValue } from '../../hooks/useKeyValue';
import type { BannerProps } from './Banner';
import { Banner } from './Banner';
import { IconButton } from './IconButton';
import { Button } from './Button';
export function DismissibleBanner({
children,
@@ -19,14 +19,17 @@ export function DismissibleBanner({
if (dismissed) return null;
return (
<Banner className={classNames(className, 'relative pr-8')} {...props}>
<IconButton
className="!absolute right-0 top-0"
icon="x"
<Banner className={classNames(className, 'relative grid grid-cols-[1fr_auto] gap-3')} {...props}>
{children}
<Button
variant="border"
color={props.color}
size="xs"
onClick={() => setDismissed((d) => !d)}
title="Dismiss message"
/>
{children}
>
Dismiss
</Button>
</Banner>
);
}

View File

@@ -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,10 @@ 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';
import { ErrorBoundary } from '../ErrorBoundary';
export type DropdownItemSeparator = {
type: 'separator';
@@ -202,17 +203,19 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
return (
<>
{child}
<Menu
ref={menuRef}
showTriangle
triggerRef={buttonRef}
fullWidth={fullWidth}
defaultSelectedIndex={defaultSelectedIndex}
items={items}
triggerShape={triggerRect ?? null}
onClose={() => setIsOpen(false)}
isOpen={isOpen}
/>
<ErrorBoundary name={`Dropdown Menu`}>
<Menu
ref={menuRef}
showTriangle
triggerRef={buttonRef}
fullWidth={fullWidth}
defaultSelectedIndex={defaultSelectedIndex}
items={items}
triggerShape={triggerRect ?? null}
onClose={() => setIsOpen(false)}
isOpen={isOpen}
/>
</ErrorBoundary>
</>
);
});

View File

@@ -102,11 +102,13 @@
}
.cm-scroller {
@apply font-mono text-xs overflow-hidden;
}
@apply font-mono text-xs;
.cm-line {
@apply overflow-hidden;
/* Hide scrollbars */
&::-webkit-scrollbar-corner,
&::-webkit-scrollbar {
@apply hidden !important;
}
}
}

View File

@@ -2,16 +2,16 @@ import { defaultKeymap, historyField, indentWithTab } from '@codemirror/commands
import { foldState, forceParsing } from '@codemirror/language';
import type { EditorStateConfig, Extension } from '@codemirror/state';
import { Compartment, EditorState } from '@codemirror/state';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import { EditorView, keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import { emacs } from '@replit/codemirror-emacs';
import { vim } from '@replit/codemirror-vim';
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
import type { EditorKeymap, EnvironmentVariable } from '@yaakapp-internal/models';
import { settingsAtom } from '@yaakapp-internal/models';
import type { EditorLanguage, TemplateFunction } from '@yaakapp-internal/plugins';
import { parseTemplate } from '@yaakapp-internal/templates';
import classNames from 'classnames';
import { EditorView } from 'codemirror';
import { useAtomValue } from 'jotai';
import { md5 } from 'js-md5';
import type { MutableRefObject, ReactNode } from 'react';
@@ -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<string>;
heightMode?: 'auto' | 'full';
@@ -108,6 +110,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
disableTabIndent,
disabled,
extraExtensions,
forcedEnvironmentId,
forceUpdateKey,
format,
heightMode,
@@ -130,7 +133,9 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(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);
@@ -404,7 +409,6 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
onClickMissingVariable,
onClickPathParameter,
});
const extensions = [
languageCompartment.of(langExt),
placeholderCompartment.current.of(placeholderExt(placeholderElFromText(placeholder))),

View File

@@ -6,9 +6,7 @@ import {
} from '@codemirror/autocomplete';
import { history, historyKeymap } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { xml } from '@codemirror/lang-xml';
import type { LanguageSupport } from '@codemirror/language';
import {
codeFolding,
@@ -27,6 +25,7 @@ import {
crosshairCursor,
drawSelection,
dropCursor,
EditorView,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
@@ -36,14 +35,15 @@ import {
import { tags as t } from '@lezer/highlight';
import type { EnvironmentVariable } from '@yaakapp-internal/models';
import { graphql } from 'cm6-graphql';
import { EditorView } from 'codemirror';
import { pluralizeCount } from '../../../lib/pluralize';
import type { EditorProps } from './Editor';
import { pairs } from './pairs/extension';
import { text } from './text/extension';
import type { TwigCompletionOption } from './twig/completion';
import { twig } from './twig/extension';
import { pathParametersPlugin } from './twig/pathParameters';
import { json } from '@codemirror/lang-json';
import { xml } from '@codemirror/lang-xml';
import { pairs } from './pairs/extension';
import { url } from './url/extension';
export const syntaxHighlightStyle = HighlightStyle.define([
@@ -75,16 +75,20 @@ const syntaxTheme = EditorView.theme({}, { dark: true });
const closeBracketsExtensions: Extension = [closeBrackets(), keymap.of([...closeBracketsKeymap])];
const syntaxExtensions: Record<NonNullable<EditorProps['language']>, LanguageSupport | null> = {
const syntaxExtensions: Record<
NonNullable<EditorProps['language']>,
null | (() => LanguageSupport)
> = {
graphql: null,
json: json(),
javascript: javascript(),
html: xml(), // HTML as XML because HTML is oddly slow
xml: xml(),
url: url(),
pairs: pairs(),
text: text(),
markdown: markdown(),
json: json,
javascript: javascript,
// HTML as XML because HTML is oddly slow
html: xml,
xml: xml,
url: url,
pairs: pairs,
text: text,
markdown: markdown,
};
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ['json', 'javascript', 'graphql'];
@@ -122,7 +126,8 @@ export function getLanguageExtension({
return [graphql(), extraExtensions];
}
const base = syntaxExtensions[language ?? 'text'] ?? text();
const base_ = syntaxExtensions[language ?? 'text'] ?? text();
const base = typeof base_ === 'function' ? base_() : text();
if (!useTemplating) {
return [base, extraExtensions];

View File

@@ -1,6 +1,5 @@
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, hoverTooltip, MatchDecorator, ViewPlugin } from '@codemirror/view';
import { EditorView } from 'codemirror';
import { Decoration, EditorView, hoverTooltip, MatchDecorator, ViewPlugin } from '@codemirror/view';
const REGEX =
/(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+*~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+*.~#?&/={}[\]]*))/g;

View File

@@ -1,8 +1,7 @@
import { syntaxTree } from '@codemirror/language';
import type { Range } from '@codemirror/state';
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, ViewPlugin, WidgetType } from '@codemirror/view';
import { EditorView } from 'codemirror';
import { Decoration, ViewPlugin, WidgetType, EditorView } from '@codemirror/view';
class PathPlaceholderWidget extends WidgetType {
readonly #clickListenerCallback: () => void;

View File

@@ -1,9 +1,8 @@
import { syntaxTree } from '@codemirror/language';
import type { Range } from '@codemirror/state';
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, ViewPlugin, WidgetType } from '@codemirror/view';
import { Decoration, ViewPlugin, WidgetType, EditorView } from '@codemirror/view';
import type { SyntaxNodeRef } from '@lezer/common';
import { EditorView } from 'codemirror';
import type { TwigCompletionOption } from './completion';
class TemplateTagWidget extends WidgetType {

View File

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

View File

@@ -7,13 +7,25 @@ import { Tooltip } from './Tooltip';
type Props = Omit<TooltipProps, 'children'> & {
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 (
<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>
);
}

View File

@@ -1,6 +1,6 @@
import type { EditorView } from '@codemirror/view';
import type { Color } from '@yaakapp/api';
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import type { ReactNode } from 'react';
import {
forwardRef,
@@ -13,6 +13,7 @@ import {
} from 'react';
import { useIsEncryptionEnabled } from '../../hooks/useIsEncryptionEnabled';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import { copyToClipboard } from '../../lib/copy';
import {
analyzeTemplate,
convertTemplateToInsecure,
@@ -35,6 +36,7 @@ export type InputProps = Pick<
EditorProps,
| 'language'
| 'autocomplete'
| 'forcedEnvironmentId'
| 'forceUpdateKey'
| 'disabled'
| 'autoFocus'
@@ -125,13 +127,29 @@ const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
useImperativeHandle<EditorView | null, EditorView | null>(ref, () => editorRef.current);
const lastWindowFocus = useRef<number>(0);
useEffect(() => {
const fn = () => (lastWindowFocus.current = Date.now());
window.addEventListener('focus', fn);
return () => {
window.removeEventListener('focus', fn);
};
}, []);
const handleFocus = useCallback(() => {
if (readOnly) return;
// Select all text of input when it's focused to match standard browser behavior.
// This should not, however, select when the input is focused due to a window focus event, so
// we handle that case as well.
const windowJustFocused = Date.now() - lastWindowFocus.current < 200;
if (!windowJustFocused) {
editorRef.current?.dispatch({
selection: { anchor: 0, head: editorRef.current.state.doc.length },
});
}
setFocused(true);
// Select all text on focus
editorRef.current?.dispatch({
selection: { anchor: 0, head: editorRef.current.state.doc.length },
});
onFocus?.();
}, [onFocus, readOnly]);
@@ -387,19 +405,32 @@ function EncryptionInput({
const dropdownItems = useMemo<DropdownItem[]>(
() => [
{
label: state.obscured ? 'Reveal value' : 'Conceal value',
label: state.obscured ? 'Reveal' : 'Conceal',
disabled: isEncryptionEnabled && state.fieldType === 'text',
leftSlot: <Icon icon={state.obscured ? 'eye' : 'eye_closed'} />,
onSelect: () => setState((s) => ({ ...s, obscured: !s.obscured })),
},
{
label: 'Copy',
leftSlot: <Icon icon="copy" />,
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: <Icon icon={state.fieldType === 'text' ? 'lock' : 'lock_open'} />,
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'];
@@ -457,6 +488,7 @@ function EncryptionInput({
tint={tint}
type={type}
rightSlot={rightSlot}
className="pr-1.5" // To account for encryption dropdown
{...props}
/>
);

View File

@@ -1,5 +1,5 @@
import type { EditorView } from '@codemirror/view';
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import {
forwardRef,
Fragment,
@@ -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<PairEditorRef, PairEditorProps>(function Pa
allowFileValues,
allowMultilineValues,
className,
forcedEnvironmentId,
forceUpdateKey,
nameAutocomplete,
nameAutocompleteFunctions,
@@ -219,7 +221,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
'pb-2 mb-auto h-full',
!noScroll && 'overflow-y-auto max-h-full',
// Move over the width of the drag handle
'-ml-3 -mr-2 pr-2',
'-mr-2 pr-2',
// Pad to make room for the drag divider
'pt-0.5',
)}
@@ -235,6 +237,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(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,
@@ -453,26 +458,26 @@ function PairEditorRow({
!pair.enabled && 'opacity-60',
)}
>
<Checkbox
hideLabel
title={pair.enabled ? 'Disable item' : 'Enable item'}
disabled={isLast}
checked={isLast ? false : !!pair.enabled}
className={classNames(isLast && '!opacity-disabled')}
onChange={handleChangeEnabled}
/>
{!isLast ? (
<div
className={classNames(
'py-2 h-7 w-3 flex items-center',
'py-2 h-7 w-4 flex items-center',
'justify-center opacity-0 group-hover:opacity-70',
)}
>
<Icon size="sm" icon="grip_vertical" className="pointer-events-none" />
</div>
) : (
<span className="w-3" />
<span className="w-4" />
)}
<Checkbox
hideLabel
title={pair.enabled ? 'Disable item' : 'Enable item'}
disabled={isLast}
checked={isLast ? false : !!pair.enabled}
className={classNames('pr-2', isLast && '!opacity-disabled')}
onChange={handleChangeEnabled}
/>
<div
className={classNames(
'grid items-center',
@@ -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"

View File

@@ -8,6 +8,7 @@ import { PairEditor } from './PairEditor';
interface Props extends PairEditorProps {
preferenceName: string;
forcedEnvironmentId?: string;
}
export const PairOrBulkEditor = forwardRef<PairEditorRef, Props>(function PairOrBulkEditor(

View File

@@ -5,6 +5,7 @@ import { Icon } from '../Icon';
import type { RadioDropdownProps } from '../RadioDropdown';
import { RadioDropdown } from '../RadioDropdown';
import { HStack } from '../Stacks';
import { ErrorBoundary } from '../../ErrorBoundary';
export type TabItem =
| {
@@ -153,12 +154,14 @@ export const TabContent = memo(function TabContent({
className,
}: TabContentProps) {
return (
<div
tabIndex={-1}
data-tab={value}
className={classNames(className, 'tab-content', 'hidden w-full h-full')}
>
{children}
</div>
<ErrorBoundary name={`Tab ${value}`}>
<div
tabIndex={-1}
data-tab={value}
className={classNames(className, 'tab-content', 'hidden w-full h-full')}
>
{children}
</div>
</ErrorBoundary>
);
});

View File

@@ -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<CSSProperties>();
const triggerRef = useRef<HTMLButtonElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
@@ -89,11 +90,12 @@ export function Tooltip({ children, content, size = 'md' }: TooltipProps) {
<Triangle className="text-border mb-2" />
</div>
</Portal>
<button
<span
ref={triggerRef}
type="button"
role="button"
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}
onMouseEnter={handleOpen}
onMouseLeave={handleClose}
@@ -102,7 +104,7 @@ export function Tooltip({ children, content, size = 'md' }: TooltipProps) {
onKeyDown={handleKeyDown}
>
{children}
</button>
</span>
</>
);
}

View File

@@ -7,6 +7,13 @@ import React, { useRef, useState } from 'react';
import { Document, Page } from 'react-pdf';
import { useContainerSize } from '../../hooks/useContainerQuery';
import('react-pdf').then(({ pdfjs }) => {
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
});
interface Props {
bodyPath: string;
}

View File

@@ -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<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]);
return useEnvironmentVariables(activeEnvironment?.id ?? null);
}

View File

@@ -1,26 +0,0 @@
import { clear, writeText } from '@tauri-apps/plugin-clipboard-manager';
import { useCallback } from 'react';
import { showToast } from '../lib/toast';
export function useCopy({ disableToast }: { disableToast?: boolean } = {}) {
const copy = useCallback(
(text: string | null) => {
if (text == null) {
clear().catch(console.error);
} else {
writeText(text).catch(console.error);
}
if (text != '' && !disableToast) {
showToast({
id: 'copied',
color: 'success',
icon: 'copy',
message: 'Copied to clipboard',
});
}
},
[disableToast],
);
return copy;
}

View File

@@ -1,15 +1,14 @@
import { useFastMutation } from './useFastMutation';
import type { HttpResponse } from '@yaakapp-internal/models';
import { 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'],
mutationKey: ['copy_http_response', response.id],
async mutationFn() {
const body = await getResponseBodyText(response);
copy(body);
copyToClipboard(body);
},
});
}

View File

@@ -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<string, unknown, Environment | null>({
mutationKey: ['create_environment'],
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
return useFastMutation<string | null, unknown, Environment | null>({
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 });
},
});

View File

@@ -0,0 +1,25 @@
import type { EnvironmentVariable } from '@yaakapp-internal/models';
import { environmentsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { useEnvironmentsBreakdown } from './useEnvironmentsBreakdown';
export function useEnvironmentVariables(environmentId: string | null) {
const { baseEnvironment } = useEnvironmentsBreakdown();
const activeEnvironment =
useAtomValue(environmentsAtom).find((e) => e.id === environmentId) ?? null;
return useMemo(() => {
const varMap: Record<string, EnvironmentVariable> = {};
const allVariables = [
...(baseEnvironment?.variables ?? []),
...(activeEnvironment?.variables ?? []),
];
for (const v of allVariables) {
if (!v.enabled || !v.name) continue;
varMap[v.name] = v;
}
return Object.values(varMap);
}, [activeEnvironment, baseEnvironment]);
}

View File

@@ -5,9 +5,12 @@ import { useMemo } from 'react';
export function useEnvironmentsBreakdown() {
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]);
}

View File

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

Some files were not shown because too many files have changed in this diff Show More